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

environments/review-develop-3zknud/deployments/560^2
marcin mikołajczak 2 years ago
commit f5c3497ece

@ -57,7 +57,7 @@ lint-sass:
jest: jest:
stage: test stage: test
script: yarn test:coverage script: yarn test:coverage --runInBand
only: only:
changes: changes:
- "**/*.js" - "**/*.js"

@ -1,18 +1,20 @@
import { Map as ImmutableMap } from 'immutable'; import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api'; import { __stub } from 'soapbox/api';
import { mockStore } from 'soapbox/jest/test-helpers'; import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import rootReducer from 'soapbox/reducers'; import { ReducerRecord, EditRecord } from 'soapbox/reducers/account_notes';
import { normalizeAccount } from '../../normalizers'; import { normalizeAccount, normalizeRelationship } from '../../normalizers';
import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes'; import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes';
import type { Account } from 'soapbox/types/entities';
describe('submitAccountNote()', () => { describe('submitAccountNote()', () => {
let store; let store: ReturnType<typeof mockStore>;
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('account_notes', { edit: { account: 1, comment: 'hello' } }); .set('account_notes', ReducerRecord({ edit: EditRecord({ account: '1', comment: 'hello' }) }));
store = mockStore(state); store = mockStore(state);
}); });
@ -60,11 +62,11 @@ describe('submitAccountNote()', () => {
}); });
describe('initAccountNoteModal()', () => { describe('initAccountNoteModal()', () => {
let store; let store: ReturnType<typeof mockStore>;
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('relationships', ImmutableMap({ 1: { note: 'hello' } })); .set('relationships', ImmutableMap({ '1': normalizeRelationship({ note: 'hello' }) }));
store = mockStore(state); store = mockStore(state);
}); });
@ -75,7 +77,7 @@ describe('initAccountNoteModal()', () => {
display_name: 'Justin L', display_name: 'Justin L',
avatar: 'test.jpg', avatar: 'test.jpg',
verified: true, verified: true,
}); }) as Account;
const expectedActions = [ const expectedActions = [
{ type: 'ACCOUNT_NOTE_INIT_MODAL', account, comment: 'hello' }, { type: 'ACCOUNT_NOTE_INIT_MODAL', account, comment: 'hello' },
{ type: 'MODAL_OPEN', modalType: 'ACCOUNT_NOTE' }, { type: 'MODAL_OPEN', modalType: 'ACCOUNT_NOTE' },
@ -88,10 +90,10 @@ describe('initAccountNoteModal()', () => {
}); });
describe('changeAccountNoteComment()', () => { describe('changeAccountNoteComment()', () => {
let store; let store: ReturnType<typeof mockStore>;
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}); const state = rootState;
store = mockStore(state); store = mockStore(state);
}); });

@ -1,10 +1,10 @@
import { Map as ImmutableMap } from 'immutable'; import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api'; import { __stub } from 'soapbox/api';
import { mockStore } from 'soapbox/jest/test-helpers'; import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import rootReducer from 'soapbox/reducers'; import { ListRecord, ReducerRecord } from 'soapbox/reducers/user_lists';
import { normalizeAccount } from '../../normalizers'; import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers';
import { import {
authorizeFollowRequest, authorizeFollowRequest,
blockAccount, blockAccount,
@ -28,7 +28,7 @@ import {
unsubscribeAccount, unsubscribeAccount,
} from '../accounts'; } from '../accounts';
let store; let store: ReturnType<typeof mockStore>;
describe('createAccount()', () => { describe('createAccount()', () => {
const params = { const params = {
@ -37,7 +37,7 @@ describe('createAccount()', () => {
describe('with a successful API request', () => { describe('with a successful API request', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}); const state = rootState;
store = mockStore(state); store = mockStore(state);
__stub((mock) => { __stub((mock) => {
@ -74,10 +74,10 @@ describe('fetchAccount()', () => {
avatar: 'test.jpg', avatar: 'test.jpg',
}); });
const state = rootReducer(undefined, {}) const state = rootState
.set('accounts', ImmutableMap({ .set('accounts', ImmutableMap({
[id]: account, [id]: account,
})); }) as any);
store = mockStore(state); store = mockStore(state);
@ -98,7 +98,7 @@ describe('fetchAccount()', () => {
const account = require('soapbox/__fixtures__/pleroma-account.json'); const account = require('soapbox/__fixtures__/pleroma-account.json');
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}); const state = rootState;
store = mockStore(state); store = mockStore(state);
__stub((mock) => { __stub((mock) => {
@ -125,7 +125,7 @@ describe('fetchAccount()', () => {
describe('with an unsuccessful API request', () => { describe('with an unsuccessful API request', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}); const state = rootState;
store = mockStore(state); store = mockStore(state);
__stub((mock) => { __stub((mock) => {
@ -155,7 +155,7 @@ describe('fetchAccount()', () => {
describe('fetchAccountByUsername()', () => { describe('fetchAccountByUsername()', () => {
const id = '123'; const id = '123';
const username = 'tiger'; const username = 'tiger';
let state, account; let state, account: any;
beforeEach(() => { beforeEach(() => {
account = normalizeAccount({ account = normalizeAccount({
@ -166,7 +166,7 @@ describe('fetchAccountByUsername()', () => {
birthday: undefined, birthday: undefined,
}); });
state = rootReducer(undefined, {}) state = rootState
.set('accounts', ImmutableMap({ .set('accounts', ImmutableMap({
[id]: account, [id]: account,
})); }));
@ -180,15 +180,15 @@ describe('fetchAccountByUsername()', () => {
describe('when "accountByUsername" feature is enabled', () => { describe('when "accountByUsername" feature is enabled', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('instance', { .set('instance', normalizeInstance({
version: '2.7.2 (compatible; Pleroma 2.4.52-1337-g4779199e.gleasonator+soapbox)', version: '2.7.2 (compatible; Pleroma 2.4.52-1337-g4779199e.gleasonator+soapbox)',
pleroma: ImmutableMap({ pleroma: ImmutableMap({
metadata: ImmutableMap({ metadata: ImmutableMap({
features: [], features: [],
}), }),
}), }),
}) }))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -243,15 +243,15 @@ describe('fetchAccountByUsername()', () => {
describe('when "accountLookup" feature is enabled', () => { describe('when "accountLookup" feature is enabled', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('instance', { .set('instance', normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0)', version: '3.4.1 (compatible; TruthSocial 1.0.0)',
pleroma: ImmutableMap({ pleroma: ImmutableMap({
metadata: ImmutableMap({ metadata: ImmutableMap({
features: [], features: [],
}), }),
}), }),
}) }))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -308,7 +308,7 @@ describe('fetchAccountByUsername()', () => {
describe('when using the accountSearch function', () => { describe('when using the accountSearch function', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -373,12 +373,12 @@ describe('fetchAccountByUsername()', () => {
describe('followAccount()', () => { describe('followAccount()', () => {
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
it('should do nothing', async() => { it('should do nothing', async() => {
await store.dispatch(followAccount(1)); await store.dispatch(followAccount('1'));
const actions = store.getActions(); const actions = store.getActions();
expect(actions).toEqual([]); expect(actions).toEqual([]);
@ -386,10 +386,10 @@ describe('followAccount()', () => {
}); });
describe('when logged in', () => { describe('when logged in', () => {
const id = 1; const id = '1';
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -460,12 +460,12 @@ describe('followAccount()', () => {
describe('unfollowAccount()', () => { describe('unfollowAccount()', () => {
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
it('should do nothing', async() => { it('should do nothing', async() => {
await store.dispatch(unfollowAccount(1)); await store.dispatch(unfollowAccount('1'));
const actions = store.getActions(); const actions = store.getActions();
expect(actions).toEqual([]); expect(actions).toEqual([]);
@ -473,10 +473,10 @@ describe('unfollowAccount()', () => {
}); });
describe('when logged in', () => { describe('when logged in', () => {
const id = 1; const id = '1';
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -489,7 +489,7 @@ describe('unfollowAccount()', () => {
it('should dispatch the correct actions', async() => { it('should dispatch the correct actions', async() => {
const expectedActions = [ const expectedActions = [
{ type: 'ACCOUNT_UNFOLLOW_REQUEST', id: 1, skipLoading: true }, { type: 'ACCOUNT_UNFOLLOW_REQUEST', id: '1', skipLoading: true },
{ {
type: 'ACCOUNT_UNFOLLOW_SUCCESS', type: 'ACCOUNT_UNFOLLOW_SUCCESS',
relationship: { success: true }, relationship: { success: true },
@ -534,11 +534,11 @@ describe('unfollowAccount()', () => {
}); });
describe('blockAccount()', () => { describe('blockAccount()', () => {
const id = 1; const id = '1';
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -552,7 +552,7 @@ describe('blockAccount()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -601,11 +601,11 @@ describe('blockAccount()', () => {
}); });
describe('unblockAccount()', () => { describe('unblockAccount()', () => {
const id = 1; const id = '1';
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -619,7 +619,7 @@ describe('unblockAccount()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -667,11 +667,11 @@ describe('unblockAccount()', () => {
}); });
describe('muteAccount()', () => { describe('muteAccount()', () => {
const id = 1; const id = '1';
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -685,7 +685,7 @@ describe('muteAccount()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -734,11 +734,11 @@ describe('muteAccount()', () => {
}); });
describe('unmuteAccount()', () => { describe('unmuteAccount()', () => {
const id = 1; const id = '1';
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -752,7 +752,7 @@ describe('unmuteAccount()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -800,11 +800,11 @@ describe('unmuteAccount()', () => {
}); });
describe('subscribeAccount()', () => { describe('subscribeAccount()', () => {
const id = 1; const id = '1';
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -818,7 +818,7 @@ describe('subscribeAccount()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -866,11 +866,11 @@ describe('subscribeAccount()', () => {
}); });
describe('unsubscribeAccount()', () => { describe('unsubscribeAccount()', () => {
const id = 1; const id = '1';
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -884,7 +884,7 @@ describe('unsubscribeAccount()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -936,7 +936,7 @@ describe('removeFromFollowers()', () => {
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -950,7 +950,7 @@ describe('removeFromFollowers()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -1002,7 +1002,7 @@ describe('fetchFollowers()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -1059,7 +1059,7 @@ describe('expandFollowers()', () => {
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -1073,28 +1073,28 @@ describe('expandFollowers()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('user_lists', { .set('user_lists', ReducerRecord({
followers: ImmutableMap({ followers: ImmutableMap({
[id]: { [id]: ListRecord({
next: 'next_url', next: 'next_url',
}, }),
}), }),
}) }))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
describe('when the url is null', () => { describe('when the url is null', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('user_lists', { .set('user_lists', ReducerRecord({
followers: ImmutableMap({ followers: ImmutableMap({
[id]: { [id]: ListRecord({
next: null, next: null,
}, }),
}), }),
}) }))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -1160,7 +1160,7 @@ describe('fetchFollowing()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -1217,7 +1217,7 @@ describe('expandFollowing()', () => {
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -1231,28 +1231,28 @@ describe('expandFollowing()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('user_lists', { .set('user_lists', ReducerRecord({
following: ImmutableMap({ following: ImmutableMap({
[id]: { [id]: ListRecord({
next: 'next_url', next: 'next_url',
}, }),
}), }),
}) }))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
describe('when the url is null', () => { describe('when the url is null', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('user_lists', { .set('user_lists', ReducerRecord({
following: ImmutableMap({ following: ImmutableMap({
[id]: { [id]: ListRecord({
next: null, next: null,
}, }),
}), }),
}) }))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -1318,7 +1318,7 @@ describe('fetchRelationships()', () => {
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -1332,15 +1332,15 @@ describe('fetchRelationships()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
describe('without newAccountIds', () => { describe('without newAccountIds', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('relationships', ImmutableMap({ [id]: {} })) .set('relationships', ImmutableMap({ [id]: normalizeRelationship({}) }))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -1355,7 +1355,7 @@ describe('fetchRelationships()', () => {
describe('with a successful API request', () => { describe('with a successful API request', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('relationships', ImmutableMap({})) .set('relationships', ImmutableMap({}))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
@ -1409,7 +1409,7 @@ describe('fetchRelationships()', () => {
describe('fetchFollowRequests()', () => { describe('fetchFollowRequests()', () => {
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -1423,14 +1423,14 @@ describe('fetchFollowRequests()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
describe('with a successful API request', () => { describe('with a successful API request', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('relationships', ImmutableMap({})) .set('relationships', ImmutableMap({}))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
@ -1483,7 +1483,7 @@ describe('fetchFollowRequests()', () => {
describe('expandFollowRequests()', () => { describe('expandFollowRequests()', () => {
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -1497,24 +1497,24 @@ describe('expandFollowRequests()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('user_lists', { .set('user_lists', ReducerRecord({
follow_requests: { follow_requests: ListRecord({
next: 'next_url', next: 'next_url',
}, }),
}) }))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
describe('when the url is null', () => { describe('when the url is null', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('user_lists', { .set('user_lists', ReducerRecord({
follow_requests: { follow_requests: ListRecord({
next: null, next: null,
}, }),
}) }))
.set('me', '123'); .set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });
@ -1579,7 +1579,7 @@ describe('authorizeFollowRequest()', () => {
describe('when logged out', () => { describe('when logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -1593,7 +1593,7 @@ describe('authorizeFollowRequest()', () => {
describe('when logged in', () => { describe('when logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '123'); const state = rootState.set('me', '123');
store = mockStore(state); store = mockStore(state);
}); });

@ -1,11 +1,10 @@
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { mockStore } from 'soapbox/jest/test-helpers'; import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import rootReducer from 'soapbox/reducers';
import { dismissAlert, showAlert, showAlertForError } from '../alerts'; import { dismissAlert, showAlert, showAlertForError } from '../alerts';
const buildError = (message: string, status: number) => new AxiosError<any>(message, String(status), null, null, { const buildError = (message: string, status: number) => new AxiosError<any>(message, String(status), undefined, null, {
data: { data: {
error: message, error: message,
}, },
@ -15,10 +14,10 @@ const buildError = (message: string, status: number) => new AxiosError<any>(mess
config: {}, config: {},
}); });
let store; let store: ReturnType<typeof mockStore>;
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}); const state = rootState;
store = mockStore(state); store = mockStore(state);
}); });
@ -28,7 +27,7 @@ describe('dismissAlert()', () => {
const expectedActions = [ const expectedActions = [
{ type: 'ALERT_DISMISS', alert }, { type: 'ALERT_DISMISS', alert },
]; ];
await store.dispatch(dismissAlert(alert)); await store.dispatch(dismissAlert(alert as any));
const actions = store.getActions(); const actions = store.getActions();
expect(actions).toEqual(expectedActions); expect(actions).toEqual(expectedActions);
@ -70,11 +69,10 @@ describe('showAlert()', () => {
it('dispatches the proper actions', async() => { it('dispatches the proper actions', async() => {
const error = buildError('', 404); const error = buildError('', 404);
const expectedActions = [];
await store.dispatch(showAlertForError(error)); await store.dispatch(showAlertForError(error));
const actions = store.getActions(); const actions = store.getActions();
expect(actions).toEqual(expectedActions); expect(actions).toEqual([]);
}); });
}); });
@ -82,11 +80,10 @@ describe('showAlert()', () => {
it('dispatches the proper actions', async() => { it('dispatches the proper actions', async() => {
const error = buildError('', 410); const error = buildError('', 410);
const expectedActions = [];
await store.dispatch(showAlertForError(error)); await store.dispatch(showAlertForError(error));
const actions = store.getActions(); const actions = store.getActions();
expect(actions).toEqual(expectedActions); expect(actions).toEqual([]);
}); });
}); });

@ -1,8 +1,6 @@
import { Record as ImmutableRecord } from 'immutable';
import { __stub } from 'soapbox/api'; import { __stub } from 'soapbox/api';
import { mockStore } from 'soapbox/jest/test-helpers'; import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import rootReducer from 'soapbox/reducers'; import { ListRecord, ReducerRecord as UserListsRecord } from 'soapbox/reducers/user_lists';
import { expandBlocks, fetchBlocks } from '../blocks'; import { expandBlocks, fetchBlocks } from '../blocks';
@ -14,11 +12,11 @@ const account = {
}; };
describe('fetchBlocks()', () => { describe('fetchBlocks()', () => {
let store; let store: ReturnType<typeof mockStore>;
describe('if logged out', () => { describe('if logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -32,7 +30,7 @@ describe('fetchBlocks()', () => {
describe('if logged in', () => { describe('if logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '1234'); const state = rootState.set('me', '1234');
store = mockStore(state); store = mockStore(state);
}); });
@ -87,11 +85,11 @@ describe('fetchBlocks()', () => {
}); });
describe('expandBlocks()', () => { describe('expandBlocks()', () => {
let store; let store: ReturnType<typeof mockStore>;
describe('if logged out', () => { describe('if logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -105,15 +103,15 @@ describe('expandBlocks()', () => {
describe('if logged in', () => { describe('if logged in', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', '1234'); const state = rootState.set('me', '1234');
store = mockStore(state); store = mockStore(state);
}); });
describe('without a url', () => { describe('without a url', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('me', '1234') .set('me', '1234')
.set('user_lists', ImmutableRecord({ blocks: { next: null } })()); .set('user_lists', UserListsRecord({ blocks: ListRecord({ next: null }) }));
store = mockStore(state); store = mockStore(state);
}); });
@ -127,9 +125,9 @@ describe('expandBlocks()', () => {
describe('with a url', () => { describe('with a url', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('me', '1234') .set('me', '1234')
.set('user_lists', ImmutableRecord({ blocks: { next: 'example' } })()); .set('user_lists', UserListsRecord({ blocks: ListRecord({ next: 'example' }) }));
store = mockStore(state); store = mockStore(state);
}); });

@ -4,14 +4,14 @@ import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { fetchCarouselAvatars } from '../carousels'; import { fetchCarouselAvatars } from '../carousels';
describe('fetchCarouselAvatars()', () => { describe('fetchCarouselAvatars()', () => {
let store; let store: ReturnType<typeof mockStore>;
beforeEach(() => { beforeEach(() => {
store = mockStore(rootState); store = mockStore(rootState);
}); });
describe('with a successful API request', () => { describe('with a successful API request', () => {
let avatars; let avatars: Record<string, any>[];
beforeEach(() => { beforeEach(() => {
avatars = [ avatars = [

@ -1,28 +1,29 @@
import { fromJS } from 'immutable'; import { Map as ImmutableMap } from 'immutable';
import { mockStore } from 'soapbox/jest/test-helpers'; import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { InstanceRecord } from 'soapbox/normalizers'; import { InstanceRecord } from 'soapbox/normalizers';
import rootReducer from 'soapbox/reducers';
import { uploadCompose } from '../compose'; import { uploadCompose } from '../compose';
import type { IntlShape } from 'react-intl';
describe('uploadCompose()', () => { describe('uploadCompose()', () => {
describe('with images', () => { describe('with images', () => {
let files, store; let files: FileList, store: ReturnType<typeof mockStore>;
beforeEach(() => { beforeEach(() => {
const instance = InstanceRecord({ const instance = InstanceRecord({
configuration: fromJS({ configuration: ImmutableMap({
statuses: { statuses: ImmutableMap({
max_media_attachments: 4, max_media_attachments: 4,
}, }),
media_attachments: { media_attachments: ImmutableMap({
image_size_limit: 10, image_size_limit: 10,
}, }),
}), }),
}); });
const state = rootReducer(undefined, {}) const state = rootState
.set('me', '1234') .set('me', '1234')
.set('instance', instance); .set('instance', instance);
@ -32,13 +33,13 @@ describe('uploadCompose()', () => {
name: 'Image', name: 'Image',
size: 15, size: 15,
type: 'image/png', type: 'image/png',
}]; }] as unknown as FileList;
}); });
it('creates an alert if exceeds max size', async() => { it('creates an alert if exceeds max size', async() => {
const mockIntl = { const mockIntl = {
formatMessage: jest.fn().mockReturnValue('Image exceeds the current file size limit (10 Bytes)'), formatMessage: jest.fn().mockReturnValue('Image exceeds the current file size limit (10 Bytes)'),
}; } as unknown as IntlShape;
const expectedActions = [ const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true },
@ -60,21 +61,21 @@ describe('uploadCompose()', () => {
}); });
describe('with videos', () => { describe('with videos', () => {
let files, store; let files: FileList, store: ReturnType<typeof mockStore>;
beforeEach(() => { beforeEach(() => {
const instance = InstanceRecord({ const instance = InstanceRecord({
configuration: fromJS({ configuration: ImmutableMap({
statuses: { statuses: ImmutableMap({
max_media_attachments: 4, max_media_attachments: 4,
}, }),
media_attachments: { media_attachments: ImmutableMap({
video_size_limit: 10, video_size_limit: 10,
}, }),
}), }),
}); });
const state = rootReducer(undefined, {}) const state = rootState
.set('me', '1234') .set('me', '1234')
.set('instance', instance); .set('instance', instance);
@ -84,13 +85,13 @@ describe('uploadCompose()', () => {
name: 'Video', name: 'Video',
size: 15, size: 15,
type: 'video/mp4', type: 'video/mp4',
}]; }] as unknown as FileList;
}); });
it('creates an alert if exceeds max size', async() => { it('creates an alert if exceeds max size', async() => {
const mockIntl = { const mockIntl = {
formatMessage: jest.fn().mockReturnValue('Video exceeds the current file size limit (10 Bytes)'), formatMessage: jest.fn().mockReturnValue('Video exceeds the current file size limit (10 Bytes)'),
}; } as unknown as IntlShape;
const expectedActions = [ const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true },

@ -1,5 +1,4 @@
import { mockStore, mockWindowProperty } from 'soapbox/jest/test-helpers'; import { mockStore, mockWindowProperty, rootState } from 'soapbox/jest/test-helpers';
import rootReducer from 'soapbox/reducers';
import { checkOnboardingStatus, startOnboarding, endOnboarding } from '../onboarding'; import { checkOnboardingStatus, startOnboarding, endOnboarding } from '../onboarding';
@ -17,7 +16,7 @@ describe('checkOnboarding()', () => {
it('does nothing if localStorage item is not set', async() => { it('does nothing if localStorage item is not set', async() => {
mockGetItem = jest.fn().mockReturnValue(null); mockGetItem = jest.fn().mockReturnValue(null);
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); const state = rootState.setIn(['onboarding', 'needsOnboarding'], false);
const store = mockStore(state); const store = mockStore(state);
await store.dispatch(checkOnboardingStatus()); await store.dispatch(checkOnboardingStatus());
@ -30,7 +29,7 @@ describe('checkOnboarding()', () => {
it('does nothing if localStorage item is invalid', async() => { it('does nothing if localStorage item is invalid', async() => {
mockGetItem = jest.fn().mockReturnValue('invalid'); mockGetItem = jest.fn().mockReturnValue('invalid');
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); const state = rootState.setIn(['onboarding', 'needsOnboarding'], false);
const store = mockStore(state); const store = mockStore(state);
await store.dispatch(checkOnboardingStatus()); await store.dispatch(checkOnboardingStatus());
@ -43,7 +42,7 @@ describe('checkOnboarding()', () => {
it('dispatches the correct action', async() => { it('dispatches the correct action', async() => {
mockGetItem = jest.fn().mockReturnValue('1'); mockGetItem = jest.fn().mockReturnValue('1');
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); const state = rootState.setIn(['onboarding', 'needsOnboarding'], false);
const store = mockStore(state); const store = mockStore(state);
await store.dispatch(checkOnboardingStatus()); await store.dispatch(checkOnboardingStatus());
@ -66,7 +65,7 @@ describe('startOnboarding()', () => {
}); });
it('dispatches the correct action', async() => { it('dispatches the correct action', async() => {
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); const state = rootState.setIn(['onboarding', 'needsOnboarding'], false);
const store = mockStore(state); const store = mockStore(state);
await store.dispatch(startOnboarding()); await store.dispatch(startOnboarding());
@ -89,7 +88,7 @@ describe('endOnboarding()', () => {
}); });
it('dispatches the correct action', async() => { it('dispatches the correct action', async() => {
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); const state = rootState.setIn(['onboarding', 'needsOnboarding'], false);
const store = mockStore(state); const store = mockStore(state);
await store.dispatch(endOnboarding()); await store.dispatch(endOnboarding());

@ -4,7 +4,6 @@ import { STATUSES_IMPORT } from 'soapbox/actions/importer';
import { __stub } from 'soapbox/api'; import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers'; import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { normalizeStatus } from 'soapbox/normalizers/status'; import { normalizeStatus } from 'soapbox/normalizers/status';
import rootReducer from 'soapbox/reducers';
import { deleteStatus, fetchContext } from '../statuses'; import { deleteStatus, fetchContext } from '../statuses';
@ -19,7 +18,7 @@ describe('fetchContext()', () => {
const store = mockStore(rootState); const store = mockStore(rootState);
store.dispatch(fetchContext('017ed505-5926-392f-256a-f86d5075df70')).then(context => { store.dispatch(fetchContext('017ed505-5926-392f-256a-f86d5075df70')).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[3].type).toEqual(STATUSES_IMPORT); expect(actions[3].type).toEqual(STATUSES_IMPORT);
@ -31,11 +30,11 @@ describe('fetchContext()', () => {
}); });
describe('deleteStatus()', () => { describe('deleteStatus()', () => {
let store; let store: ReturnType<typeof mockStore>;
describe('if logged out', () => { describe('if logged out', () => {
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}).set('me', null); const state = rootState.set('me', null);
store = mockStore(state); store = mockStore(state);
}); });
@ -54,16 +53,16 @@ describe('deleteStatus()', () => {
}); });
beforeEach(() => { beforeEach(() => {
const state = rootReducer(undefined, {}) const state = rootState
.set('me', '1234') .set('me', '1234')
.set('statuses', fromJS({ .set('statuses', fromJS({
[statusId]: cachedStatus, [statusId]: cachedStatus,
})); }) as any);
store = mockStore(state); store = mockStore(state);
}); });
describe('with a successful API request', () => { describe('with a successful API request', () => {
let status; let status: any;
beforeEach(() => { beforeEach(() => {
status = require('soapbox/__fixtures__/pleroma-status-deleted.json'); status = require('soapbox/__fixtures__/pleroma-status-deleted.json');

@ -180,8 +180,8 @@ export const verifyCredentials = (token: string, accountUrl?: string) => {
return account; return account;
} else { } else {
if (getState().me === null) dispatch(fetchMeFail(error)); if (getState().me === null) dispatch(fetchMeFail(error));
dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error, skipAlert: true }); dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error });
return error; throw error;
} }
}); });
}; };
@ -214,14 +214,6 @@ export const logIn = (username: string, password: string) =>
if ((error.response?.data as any).error === 'mfa_required') { if ((error.response?.data as any).error === 'mfa_required') {
// If MFA is required, throw the error and handle it in the component. // If MFA is required, throw the error and handle it in the component.
throw error; throw error;
} else if ((error.response?.data as any).error === 'invalid_grant') {
// Mastodon returns this user-unfriendly error as a catch-all
// for everything from "bad request" to "wrong password".
// Assume our code is correct and it's a wrong password.
dispatch(snackbar.error(messages.invalidCredentials));
} else if ((error.response?.data as any).error) {
// If the backend returns an error, display it.
dispatch(snackbar.error((error.response?.data as any).error));
} else { } else {
// Return "wrong password" message. // Return "wrong password" message.
dispatch(snackbar.error(messages.invalidCredentials)); dispatch(snackbar.error(messages.invalidCredentials));

@ -6,7 +6,7 @@ import api from '../api';
import { loadCredentials } from './auth'; import { loadCredentials } from './auth';
import { importFetchedAccount } from './importer'; import { importFetchedAccount } from './importer';
import type { AxiosError } from 'axios'; import type { AxiosError, AxiosRequestHeaders } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities'; import type { APIEntity } from 'soapbox/types/entities';
@ -62,12 +62,16 @@ const persistAuthAccount = (account: APIEntity, params: Record<string, any>) =>
} }
}; };
const patchMe = (params: Record<string, any>) => const patchMe = (params: Record<string, any>, isFormData = false) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(patchMeRequest()); dispatch(patchMeRequest());
const headers: AxiosRequestHeaders = isFormData ? {
'Content-Type': 'multipart/form-data',
} : {};
return api(getState) return api(getState)
.patch('/api/v1/accounts/update_credentials', params) .patch('/api/v1/accounts/update_credentials', params, { headers })
.then(response => { .then(response => {
persistAuthAccount(response.data, params); persistAuthAccount(response.data, params);
dispatch(patchMeSuccess(response.data)); dispatch(patchMeSuccess(response.data));

@ -5,6 +5,8 @@ import { render, screen } from '../../jest/test-helpers';
import { normalizeAccount } from '../../normalizers'; import { normalizeAccount } from '../../normalizers';
import Account from '../account'; import Account from '../account';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
describe('<Account />', () => { describe('<Account />', () => {
it('renders account name and username', () => { it('renders account name and username', () => {
const account = normalizeAccount({ const account = normalizeAccount({
@ -12,7 +14,7 @@ describe('<Account />', () => {
acct: 'justin-username', acct: 'justin-username',
display_name: 'Justin L', display_name: 'Justin L',
avatar: 'test.jpg', avatar: 'test.jpg',
}); }) as ReducerAccount;
const store = { const store = {
accounts: ImmutableMap({ accounts: ImmutableMap({
@ -20,7 +22,7 @@ describe('<Account />', () => {
}), }),
}; };
render(<Account account={account} />, null, store); render(<Account account={account} />, undefined, store);
expect(screen.getByTestId('account')).toHaveTextContent('Justin L'); expect(screen.getByTestId('account')).toHaveTextContent('Justin L');
expect(screen.getByTestId('account')).toHaveTextContent(/justin-username/i); expect(screen.getByTestId('account')).toHaveTextContent(/justin-username/i);
}); });
@ -33,7 +35,7 @@ describe('<Account />', () => {
display_name: 'Justin L', display_name: 'Justin L',
avatar: 'test.jpg', avatar: 'test.jpg',
verified: true, verified: true,
}); }) as ReducerAccount;
const store = { const store = {
accounts: ImmutableMap({ accounts: ImmutableMap({
@ -41,7 +43,7 @@ describe('<Account />', () => {
}), }),
}; };
render(<Account account={account} />, null, store); render(<Account account={account} />, undefined, store);
expect(screen.getByTestId('verified-badge')).toBeInTheDocument(); expect(screen.getByTestId('verified-badge')).toBeInTheDocument();
}); });
@ -52,7 +54,7 @@ describe('<Account />', () => {
display_name: 'Justin L', display_name: 'Justin L',
avatar: 'test.jpg', avatar: 'test.jpg',
verified: false, verified: false,
}); }) as ReducerAccount;
const store = { const store = {
accounts: ImmutableMap({ accounts: ImmutableMap({
@ -60,7 +62,7 @@ describe('<Account />', () => {
}), }),
}; };
render(<Account account={account} />, null, store); render(<Account account={account} />, undefined, store);
expect(screen.queryAllByTestId('verified-badge')).toHaveLength(0); expect(screen.queryAllByTestId('verified-badge')).toHaveLength(0);
}); });
}); });

@ -5,6 +5,8 @@ import { normalizeAccount } from 'soapbox/normalizers';
import { render, screen } from '../../jest/test-helpers'; import { render, screen } from '../../jest/test-helpers';
import Avatar from '../avatar'; import Avatar from '../avatar';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
describe('<Avatar />', () => { describe('<Avatar />', () => {
const account = normalizeAccount({ const account = normalizeAccount({
username: 'alice', username: 'alice',
@ -12,7 +14,7 @@ describe('<Avatar />', () => {
display_name: 'Alice', display_name: 'Alice',
avatar: '/animated/alice.gif', avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg', avatar_static: '/static/alice.jpg',
}); }) as ReducerAccount;
const size = 100; const size = 100;

@ -1,25 +1,28 @@
import { fromJS } from 'immutable';
import React from 'react'; import React from 'react';
import { normalizeAccount } from 'soapbox/normalizers';
import { render, screen } from '../../jest/test-helpers'; import { render, screen } from '../../jest/test-helpers';
import AvatarOverlay from '../avatar_overlay'; import AvatarOverlay from '../avatar_overlay';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
describe('<AvatarOverlay', () => { describe('<AvatarOverlay', () => {
const account = fromJS({ const account = normalizeAccount({
username: 'alice', username: 'alice',
acct: 'alice', acct: 'alice',
display_name: 'Alice', display_name: 'Alice',
avatar: '/animated/alice.gif', avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg', avatar_static: '/static/alice.jpg',
}); }) as ReducerAccount;
const friend = fromJS({ const friend = normalizeAccount({
username: 'eve', username: 'eve',
acct: 'eve@blackhat.lair', acct: 'eve@blackhat.lair',
display_name: 'Evelyn', display_name: 'Evelyn',
avatar: '/animated/eve.gif', avatar: '/animated/eve.gif',
avatar_static: '/static/eve.jpg', avatar_static: '/static/eve.jpg',
}); }) as ReducerAccount;
it('renders a overlay avatar', () => { it('renders a overlay avatar', () => {
render(<AvatarOverlay account={account} friend={friend} />); render(<AvatarOverlay account={account} friend={friend} />);

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

@ -6,6 +6,7 @@ import EmojiSelector from '../emoji_selector';
describe('<EmojiSelector />', () => { describe('<EmojiSelector />', () => {
it('renders correctly', () => { it('renders correctly', () => {
const children = <EmojiSelector />; const children = <EmojiSelector />;
// @ts-ignore
children.__proto__.addEventListener = () => {}; children.__proto__.addEventListener = () => {};
render(children); render(children);

@ -4,6 +4,8 @@ import { render, screen, rootState } from '../../jest/test-helpers';
import { normalizeStatus, normalizeAccount } from '../../normalizers'; import { normalizeStatus, normalizeAccount } from '../../normalizers';
import QuotedStatus from '../quoted-status'; import QuotedStatus from '../quoted-status';
import type { ReducerStatus } from 'soapbox/reducers/statuses';
describe('<QuotedStatus />', () => { describe('<QuotedStatus />', () => {
it('renders content', () => { it('renders content', () => {
const account = normalizeAccount({ const account = normalizeAccount({
@ -16,11 +18,11 @@ describe('<QuotedStatus />', () => {
account, account,
content: 'hello world', content: 'hello world',
contentHtml: 'hello world', contentHtml: 'hello world',
}); }) as ReducerStatus;
const state = rootState.setIn(['accounts', '1', account]); const state = rootState.setIn(['accounts', '1'], account);
render(<QuotedStatus status={status} />, null, state); render(<QuotedStatus status={status} />, undefined, state);
screen.getByText(/hello world/i); screen.getByText(/hello world/i);
expect(screen.getByTestId('quoted-status')).toHaveTextContent(/hello world/i); expect(screen.getByTestId('quoted-status')).toHaveTextContent(/hello world/i);
}); });

@ -28,7 +28,7 @@ describe('<ScrollTopButton />', () => {
message={messages.queue} message={messages.queue}
/>, />,
); );
expect(screen.getByText('Click to see 1 new post', { hidden: true })).toBeInTheDocument(); expect(screen.getByText('Click to see 1 new post')).toBeInTheDocument();
render( render(
<ScrollTopButton <ScrollTopButton
@ -38,6 +38,6 @@ describe('<ScrollTopButton />', () => {
message={messages.queue} message={messages.queue}
/>, />,
); );
expect(screen.getByText('Click to see 9999999 new posts', { hidden: true })).toBeInTheDocument(); expect(screen.getByText('Click to see 9999999 new posts')).toBeInTheDocument();
}); });
}); });

@ -159,7 +159,7 @@ const Account = ({
return ( return (
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}> <div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'> <HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems='center' space={3} grow> <HStack alignItems='center' space={3}>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}

@ -6,7 +6,7 @@ import { Provider } from 'react-redux';
import { __stub } from 'soapbox/api'; import { __stub } from 'soapbox/api';
import { normalizePoll } from 'soapbox/normalizers/poll'; import { normalizePoll } from 'soapbox/normalizers/poll';
import { mockStore, render, rootReducer, screen } from '../../../jest/test-helpers'; import { mockStore, render, screen, rootState } from '../../../jest/test-helpers';
import PollFooter from '../poll-footer'; import PollFooter from '../poll-footer';
let poll = normalizePoll({ let poll = normalizePoll({
@ -36,7 +36,7 @@ describe('<PollFooter />', () => {
}); });
const user = userEvent.setup(); const user = userEvent.setup();
const store = mockStore(rootReducer(undefined, {})); const store = mockStore(rootState);
render( render(
<Provider store={store}> <Provider store={store}>
<IntlProvider locale='en'> <IntlProvider locale='en'>

@ -1,7 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import { Text, Icon } from 'soapbox/components/ui'; import { Text, Icon, Emoji } from 'soapbox/components/ui';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { shortNumberFormat } from 'soapbox/utils/numbers';
const COLORS = { const COLORS = {
@ -15,7 +15,7 @@ interface IStatusActionCounter {
count: number, count: number,
} }
/** Action button numerical counter, eg "5" likes */ /** Action button numerical counter, eg "5" likes. */
const StatusActionCounter: React.FC<IStatusActionCounter> = ({ count = 0 }): JSX.Element => { const StatusActionCounter: React.FC<IStatusActionCounter> = ({ count = 0 }): JSX.Element => {
return ( return (
<Text size='xs' weight='semibold' theme='inherit'> <Text size='xs' weight='semibold' theme='inherit'>
@ -31,10 +31,11 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonEleme
active?: boolean, active?: boolean,
color?: Color, color?: Color,
filled?: boolean, filled?: boolean,
emoji?: string,
} }
const StatusActionButton = React.forwardRef((props: IStatusActionButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => { const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButton>((props, ref): JSX.Element => {
const { icon, className, iconClassName, active, color, filled = false, count = 0, ...filteredProps } = props; const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, ...filteredProps } = props;
return ( return (
<button <button
@ -46,22 +47,29 @@ const StatusActionButton = React.forwardRef((props: IStatusActionButton, ref: Re
'bg-white dark:bg-transparent', 'bg-white dark:bg-transparent',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0', 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0',
{ {
'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && color === COLORS.accent, 'text-black dark:text-white': active && emoji,
'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && color === COLORS.success, 'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && !emoji && color === COLORS.accent,
'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && !emoji && color === COLORS.success,
}, },
className, className,
)} )}
{...filteredProps} {...filteredProps}
> >
<Icon {emoji ? (
src={icon} <span className='block w-6 h-6 flex items-center justify-center'>
className={classNames( <Emoji className='w-full h-full p-0.5' emoji={emoji} />
{ </span>
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent, ) : (
}, <Icon
iconClassName, src={icon}
)} className={classNames(
/> {
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
},
iconClassName,
)}
/>
)}
{(count || null) && ( {(count || null) && (
<StatusActionCounter count={count} /> <StatusActionCounter count={count} />

@ -84,8 +84,6 @@ interface IStatus extends RouteComponentProps {
onMoveDown: (statusId: string, featured?: boolean) => void, onMoveDown: (statusId: string, featured?: boolean) => void,
getScrollPosition?: () => ScrollPosition | undefined, getScrollPosition?: () => ScrollPosition | undefined,
updateScrollBottom?: (bottom: number) => void, updateScrollBottom?: (bottom: number) => void,
cacheMediaWidth: () => void,
cachedMediaWidth: number,
group: ImmutableMap<string, any>, group: ImmutableMap<string, any>,
displayMedia: string, displayMedia: string,
allowedEmoji: ImmutableList<string>, allowedEmoji: ImmutableList<string>,
@ -474,18 +472,16 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
{reblogElementMobile} {reblogElementMobile}
<div className='mb-4'> <div className='mb-4'>
<HStack justifyContent='between' alignItems='start'> <AccountContainer
<AccountContainer key={String(status.getIn(['account', 'id']))}
key={String(status.getIn(['account', 'id']))} id={String(status.getIn(['account', 'id']))}
id={String(status.getIn(['account', 'id']))} timestamp={status.created_at}
timestamp={status.created_at} timestampUrl={statusUrl}
timestampUrl={statusUrl} action={reblogElement}
action={reblogElement} hideActions={!reblogElement}
hideActions={!reblogElement} showEdit={!!status.edited_at}
showEdit={!!status.edited_at} showProfileHoverCard={this.props.hoverable}
showProfileHoverCard={this.props.hoverable} />
/>
</HStack>
</div> </div>
<div className='status__content-wrapper'> <div className='status__content-wrapper'>

@ -670,6 +670,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
color='accent' color='accent'
active={Boolean(meEmojiReact)} active={Boolean(meEmojiReact)}
count={emojiReactCount} count={emojiReactCount}
emoji={meEmojiReact}
/> />
</EmojiButtonWrapper> </EmojiButtonWrapper>
) : ( ) : (

@ -30,7 +30,7 @@ describe('<Datepicker />', () => {
); );
let daySelect: HTMLElement; let daySelect: HTMLElement;
daySelect = document.querySelector('[data-testid="datepicker-day"]'); daySelect = document.querySelector('[data-testid="datepicker-day"]') as HTMLElement;
expect(queryAllByRole(daySelect, 'option')).toHaveLength(29); expect(queryAllByRole(daySelect, 'option')).toHaveLength(29);
await userEvent.selectOptions( await userEvent.selectOptions(
@ -56,26 +56,41 @@ describe('<Datepicker />', () => {
it('calls the onChange function when the inputs change', async() => { it('calls the onChange function when the inputs change', async() => {
const handler = jest.fn(); const handler = jest.fn();
render(<Datepicker onChange={handler} />); render(<Datepicker onChange={handler} />);
const today = new Date();
/**
* A date with a different day, month, and year than today
* so this test will always pass!
*/
const notToday = new Date(
today.getFullYear() - 1, // last year
(today.getMonth() + 2) % 11, // two months from now (mod 11 because it's 0-indexed)
(today.getDate() + 2) % 28, // 2 days from now (for timezone stuff)
);
const month = notToday.toLocaleString('en-us', { month: 'long' });
const year = String(notToday.getFullYear());
const day = String(notToday.getDate());
expect(handler.mock.calls.length).toEqual(1); expect(handler.mock.calls.length).toEqual(1);
await userEvent.selectOptions( await userEvent.selectOptions(
screen.getByTestId('datepicker-month'), screen.getByTestId('datepicker-month'),
screen.getByRole('option', { name: 'February' }), screen.getByRole('option', { name: month }),
); );
expect(handler.mock.calls.length).toEqual(2); expect(handler.mock.calls.length).toEqual(2);
await userEvent.selectOptions( await userEvent.selectOptions(
screen.getByTestId('datepicker-year'), screen.getByTestId('datepicker-year'),
screen.getByRole('option', { name: '2020' }), screen.getByRole('option', { name: year }),
); );
expect(handler.mock.calls.length).toEqual(3); expect(handler.mock.calls.length).toEqual(3);
await userEvent.selectOptions( await userEvent.selectOptions(
screen.getByTestId('datepicker-day'), screen.getByTestId('datepicker-day'),
screen.getByRole('option', { name: '5' }), screen.getByRole('option', { name: day }),
); );
expect(handler.mock.calls.length).toEqual(4); expect(handler.mock.calls.length).toEqual(4);

@ -25,6 +25,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
type='button' type='button'
className={classNames('flex items-center space-x-2 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 dark:ring-offset-0 focus:ring-primary-500', { className={classNames('flex items-center space-x-2 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 dark:ring-offset-0 focus:ring-primary-500', {
'bg-white dark:bg-transparent': !transparent, 'bg-white dark:bg-transparent': !transparent,
'opacity-50': filteredProps.disabled,
}, className)} }, className)}
{...filteredProps} {...filteredProps}
data-testid='icon-button' data-testid='icon-button'

@ -83,7 +83,7 @@ const Modal: React.FC<IModal> = ({
}, [skipFocus, buttonRef]); }, [skipFocus, buttonRef]);
return ( return (
<div data-testid='modal' className={classNames('block w-full p-6 mx-auto overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto', widths[width])}> <div data-testid='modal' className={classNames('block w-full p-6 mx-auto text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto', widths[width])}>
<div className='sm:flex sm:items-start w-full justify-between'> <div className='sm:flex sm:items-start w-full justify-between'>
<div className='w-full'> <div className='w-full'>
{title && ( {title && (

@ -6,3 +6,7 @@
@apply pointer-events-none absolute px-2.5 py-1.5 rounded shadow whitespace-nowrap text-xs font-medium bg-gray-800 text-white; @apply pointer-events-none absolute px-2.5 py-1.5 rounded shadow whitespace-nowrap text-xs font-medium bg-gray-800 text-white;
z-index: 100; z-index: 100;
} }
[data-reach-tooltip-arrow] {
@apply absolute z-50 w-0 h-0 border-l-8 border-solid border-l-transparent border-r-8 border-r-transparent border-b-8 border-b-gray-800;
}

@ -1,4 +1,5 @@
import { default as ReachTooltip } from '@reach/tooltip'; import Portal from '@reach/portal';
import { TooltipPopup, useTooltip } from '@reach/tooltip';
import React from 'react'; import React from 'react';
import './tooltip.css'; import './tooltip.css';
@ -8,15 +9,55 @@ interface ITooltip {
text: string, text: string,
} }
const centered = (triggerRect: any, tooltipRect: any) => {
const triggerCenter = triggerRect.left + triggerRect.width / 2;
const left = triggerCenter - tooltipRect.width / 2;
const maxLeft = window.innerWidth - tooltipRect.width - 2;
return {
left: Math.min(Math.max(2, left), maxLeft) + window.scrollX,
top: triggerRect.bottom + 8 + window.scrollY,
};
};
/** Hoverable tooltip element. */ /** Hoverable tooltip element. */
const Tooltip: React.FC<ITooltip> = ({ const Tooltip: React.FC<ITooltip> = ({
children, children,
text, text,
}) => { }) => {
// get the props from useTooltip
const [trigger, tooltip] = useTooltip();
// destructure off what we need to position the triangle
const { isVisible, triggerRect } = tooltip;
return ( return (
<ReachTooltip label={text}> <React.Fragment>
{children} {React.cloneElement(children as any, trigger)}
</ReachTooltip>
{isVisible && (
// The Triangle. We position it relative to the trigger, not the popup
// so that collisions don't have a triangle pointing off to nowhere.
// Using a Portal may seem a little extreme, but we can keep the
// positioning logic simpler here instead of needing to consider
// the popup's position relative to the trigger and collisions
<Portal>
<div
data-reach-tooltip-arrow='true'
style={{
left:
triggerRect && triggerRect.left - 10 + triggerRect.width / 2 as any,
top: triggerRect && triggerRect.bottom + window.scrollY as any,
}}
/>
</Portal>
)}
<TooltipPopup
{...tooltip}
label={text}
aria-label={text}
position={centered}
/>
</React.Fragment>
); );
}; };

@ -5,6 +5,8 @@ import React, { useState, useEffect } from 'react';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { BrowserRouter, Switch, Redirect, Route } from 'react-router-dom'; import { BrowserRouter, Switch, Redirect, Route } from 'react-router-dom';
// @ts-ignore: it doesn't have types
import { ScrollContext } from 'react-router-scroll-4';
import { loadInstance } from 'soapbox/actions/instance'; import { loadInstance } from 'soapbox/actions/instance';
import { fetchMe } from 'soapbox/actions/me'; import { fetchMe } from 'soapbox/actions/me';
@ -115,6 +117,11 @@ const SoapboxMount = () => {
}); });
}, []); }, []);
// @ts-ignore: I don't actually know what these should be, lol
const shouldUpdateScroll = (prevRouterProps, { location }) => {
return !(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey);
};
/** Whether to display a loading indicator. */ /** Whether to display a loading indicator. */
const showLoading = [ const showLoading = [
me === null, me === null,
@ -223,17 +230,19 @@ const SoapboxMount = () => {
{helmet} {helmet}
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}> <BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
<> <ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
{renderBody()} <>
{renderBody()}
<BundleContainer fetchComponent={NotificationsContainer}>
{(Component) => <Component />} <BundleContainer fetchComponent={NotificationsContainer}>
</BundleContainer> {(Component) => <Component />}
</BundleContainer>
<BundleContainer fetchComponent={ModalContainer}>
{Component => <Component />} <BundleContainer fetchComponent={ModalContainer}>
</BundleContainer> {Component => <Component />}
</> </BundleContainer>
</>
</ScrollContext>
</BrowserRouter> </BrowserRouter>
</ErrorBoundary> </ErrorBoundary>
</IntlProvider> </IntlProvider>

@ -6,7 +6,7 @@ import CaptchaField, { NativeCaptchaField } from '../captcha';
describe('<CaptchaField />', () => { describe('<CaptchaField />', () => {
it('renders null by default', () => { it('renders null by default', () => {
render(<CaptchaField />); render(<CaptchaField idempotencyKey='' value='' />);
expect(screen.queryAllByRole('textbox')).toHaveLength(0); expect(screen.queryAllByRole('textbox')).toHaveLength(0);
}); });
@ -24,7 +24,9 @@ describe('<NativeCaptchaField />', () => {
render( render(
<NativeCaptchaField <NativeCaptchaField
captcha={captcha} captcha={captcha}
onChange={() => {}} // eslint-disable-line react/jsx-no-bind onChange={() => {}}
onClick={() => {}}
value=''
/>, />,
); );

@ -13,7 +13,7 @@ describe('<LoginForm />', () => {
}), }),
}; };
render(<LoginForm handleSubmit={mockFn} isLoading={false} />, null, store); render(<LoginForm handleSubmit={mockFn} isLoading={false} />, undefined, store);
expect(screen.getByRole('heading')).toHaveTextContent(/sign in/i); expect(screen.getByRole('heading')).toHaveTextContent(/sign in/i);
}); });
@ -26,7 +26,7 @@ describe('<LoginForm />', () => {
}), }),
}; };
render(<LoginForm handleSubmit={mockFn} isLoading={false} />, null, store); render(<LoginForm handleSubmit={mockFn} isLoading={false} />, undefined, store);
expect(screen.getByRole('heading')).toHaveTextContent(/sign in/i); expect(screen.getByRole('heading')).toHaveTextContent(/sign in/i);
}); });

@ -12,7 +12,7 @@ describe('<LoginPage />', () => {
}), }),
}; };
render(<LoginPage />, null, store); render(<LoginPage />, undefined, store);
expect(screen.getByRole('heading')).toHaveTextContent('Sign In'); expect(screen.getByRole('heading')).toHaveTextContent('Sign In');
}); });

@ -4,9 +4,19 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { updateNotificationSettings } from 'soapbox/actions/accounts'; import { updateNotificationSettings } from 'soapbox/actions/accounts';
import { patchMe } from 'soapbox/actions/me'; import { patchMe } from 'soapbox/actions/me';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import BirthdayInput from 'soapbox/components/birthday_input';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { Button, Column, Form, FormActions, FormGroup, Input, Textarea, HStack, Toggle, FileInput } from 'soapbox/components/ui'; import {
Button,
Column,
FileInput,
Form,
FormActions,
FormGroup,
HStack,
Input,
Textarea,
Toggle,
} from 'soapbox/components/ui';
import Streamfield, { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; import Streamfield, { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks'; import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks';
import { normalizeAccount } from 'soapbox/normalizers'; import { normalizeAccount } from 'soapbox/normalizers';
@ -25,25 +35,6 @@ const hidesNetwork = (account: Account): boolean => {
return Boolean(hide_followers && hide_follows && hide_followers_count && hide_follows_count); return Boolean(hide_followers && hide_follows && hide_followers_count && hide_follows_count);
}; };
/** Converts JSON objects to FormData. */
// https://stackoverflow.com/a/60286175/8811886
// @ts-ignore
const toFormData = (f => f(f))(h => f => f(x => h(h)(f)(x)))(f => fd => pk => d => {
if (d instanceof Object) {
// eslint-disable-next-line consistent-return
Object.keys(d).forEach(k => {
const v = d[k];
if (pk) k = `${pk}[${k}]`;
if (v instanceof Object && !(v instanceof Date) && !(v instanceof File)) {
return f(fd)(k)(v);
} else {
fd.append(k, v);
}
});
}
return fd;
})(new FormData())();
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.edit_profile', defaultMessage: 'Edit profile' }, heading: { id: 'column.edit_profile', defaultMessage: 'Edit profile' },
header: { id: 'edit_profile.header', defaultMessage: 'Edit Profile' }, header: { id: 'edit_profile.header', defaultMessage: 'Edit Profile' },
@ -205,9 +196,8 @@ const EditProfile: React.FC = () => {
const handleSubmit: React.FormEventHandler = (event) => { const handleSubmit: React.FormEventHandler = (event) => {
const promises = []; const promises = [];
const formData = toFormData(data);
promises.push(dispatch(patchMe(formData))); promises.push(dispatch(patchMe(data, true)));
if (features.muteStrangers) { if (features.muteStrangers) {
promises.push( promises.push(
@ -242,10 +232,6 @@ const EditProfile: React.FC = () => {
}; };
}; };
const handleBirthdayChange = (date: string) => {
updateData('birthday', date);
};
const handleHideNetworkChange: React.ChangeEventHandler<HTMLInputElement> = e => { const handleHideNetworkChange: React.ChangeEventHandler<HTMLInputElement> = e => {
const hide = e.target.checked; const hide = e.target.checked;
@ -329,9 +315,12 @@ const EditProfile: React.FC = () => {
<FormGroup <FormGroup
labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />} labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />}
> >
<BirthdayInput <Input
type='text'
value={data.birthday} value={data.birthday}
onChange={handleBirthdayChange} onChange={handleTextChange('birthday')}
placeholder='YYYY-MM-DD'
pattern='\d{4}-\d{2}-\d{2}'
/> />
</FormGroup> </FormGroup>
)} )}

@ -1,9 +1,10 @@
// @ts-ignore
import { emojiIndex } from 'emoji-mart'; import { emojiIndex } from 'emoji-mart';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import { search } from '../emoji_mart_search_light'; import { search } from '../emoji_mart_search_light';
const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']); const trimEmojis = (emoji: any) => pick(emoji, ['id', 'unified', 'native', 'custom']);
describe('emoji_index', () => { describe('emoji_index', () => {
it('should give same result for emoji_index_light and emoji-mart', () => { it('should give same result for emoji_index_light and emoji-mart', () => {
@ -46,7 +47,7 @@ describe('emoji_index', () => {
}); });
it('can include/exclude categories', () => { it('can include/exclude categories', () => {
expect(search('flag', { include: ['people'] })).toEqual([]); expect(search('flag', { include: ['people'] } as any)).toEqual([]);
expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]); expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
}); });
@ -63,9 +64,8 @@ describe('emoji_index', () => {
custom: true, custom: true,
}, },
]; ];
search('', { custom }); search('', { custom } as any);
emojiIndex.search('', { custom }); emojiIndex.search('', { custom });
const expected = [];
const lightExpected = [ const lightExpected = [
{ {
id: 'mastodon', id: 'mastodon',
@ -73,7 +73,7 @@ describe('emoji_index', () => {
}, },
]; ];
expect(search('masto').map(trimEmojis)).toEqual(lightExpected); expect(search('masto').map(trimEmojis)).toEqual(lightExpected);
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected); expect(emojiIndex.search('masto').map(trimEmojis)).toEqual([]);
}); });
it('(different behavior from emoji-mart) erases custom emoji if another is passed', () => { it('(different behavior from emoji-mart) erases custom emoji if another is passed', () => {
@ -89,11 +89,10 @@ describe('emoji_index', () => {
custom: true, custom: true,
}, },
]; ];
search('', { custom }); search('', { custom } as any);
emojiIndex.search('', { custom }); emojiIndex.search('', { custom });
const expected = []; expect(search('masto', { custom: [] } as any).map(trimEmojis)).toEqual([]);
expect(search('masto', { custom: [] }).map(trimEmojis)).toEqual(expected); expect(emojiIndex.search('masto').map(trimEmojis)).toEqual([]);
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
}); });
it('handles custom emoji', () => { it('handles custom emoji', () => {
@ -109,7 +108,7 @@ describe('emoji_index', () => {
custom: true, custom: true,
}, },
]; ];
search('', { custom }); search('', { custom } as any);
emojiIndex.search('', { custom }); emojiIndex.search('', { custom });
const expected = [ const expected = [
{ {
@ -117,15 +116,15 @@ describe('emoji_index', () => {
custom: true, custom: true,
}, },
]; ];
expect(search('masto', { custom }).map(trimEmojis)).toEqual(expected); expect(search('masto', { custom } as any).map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('masto', { custom }).map(trimEmojis)).toEqual(expected); expect(emojiIndex.search('masto', { custom }).map(trimEmojis)).toEqual(expected);
}); });
it('should filter only emojis we care about, exclude pineapple', () => { it('should filter only emojis we care about, exclude pineapple', () => {
const emojisToShowFilter = emoji => emoji.unified !== '1F34D'; const emojisToShowFilter = (emoji: any) => emoji.unified !== '1F34D';
expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id)) expect(search('apple', { emojisToShowFilter } as any).map((obj: any) => obj.id))
.not.toContain('pineapple'); .not.toContain('pineapple');
expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id)) expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj: any) => obj.id))
.not.toContain('pineapple'); .not.toContain('pineapple');
}); });

@ -18,7 +18,7 @@ jest.mock('../../../hooks/useDimensions', () => ({
}; };
describe('<FeedCarousel />', () => { describe('<FeedCarousel />', () => {
let store; let store: any;
describe('with "feedUserFiltering" disabled', () => { describe('with "feedUserFiltering" disabled', () => {
beforeEach(() => { beforeEach(() => {
@ -35,7 +35,7 @@ describe('<FeedCarousel />', () => {
}); });
it('should render nothing', () => { it('should render nothing', () => {
render(<FeedCarousel />, null, store); render(<FeedCarousel />, undefined, store);
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(0); expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(0);
}); });
@ -56,7 +56,7 @@ describe('<FeedCarousel />', () => {
}); });
it('should render the Carousel', () => { it('should render the Carousel', () => {
render(<FeedCarousel />, null, store); render(<FeedCarousel />, undefined, store);
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1); expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1);
}); });
@ -70,7 +70,7 @@ describe('<FeedCarousel />', () => {
}); });
it('renders the error message', () => { it('renders the error message', () => {
render(<FeedCarousel />, null, store); render(<FeedCarousel />, undefined, store);
expect(screen.getByTestId('feed-carousel-error')).toBeInTheDocument(); expect(screen.getByTestId('feed-carousel-error')).toBeInTheDocument();
}); });
@ -110,7 +110,7 @@ describe('<FeedCarousel />', () => {
it('should render the correct prev/next buttons', async() => { it('should render the correct prev/next buttons', async() => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<FeedCarousel />, null, store); render(<FeedCarousel />, undefined, store);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('next-page')).toBeInTheDocument(); expect(screen.getByTestId('next-page')).toBeInTheDocument();

@ -17,7 +17,7 @@ describe('<LandingPage />', () => {
}, },
}); });
render(<LandingPage />, null, state); render(<LandingPage />, undefined, state);
expect(screen.queryByTestId('registrations-open')).toBeInTheDocument(); expect(screen.queryByTestId('registrations-open')).toBeInTheDocument();
expect(screen.queryByTestId('registrations-closed')).not.toBeInTheDocument(); expect(screen.queryByTestId('registrations-closed')).not.toBeInTheDocument();
@ -34,7 +34,7 @@ describe('<LandingPage />', () => {
}, },
}); });
render(<LandingPage />, null, state); render(<LandingPage />, undefined, state);
expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument(); expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument();
expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument(); expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument();
@ -59,7 +59,7 @@ describe('<LandingPage />', () => {
}, },
}], rootReducer); }], rootReducer);
render(<LandingPage />, null, state); render(<LandingPage />, undefined, state);
expect(screen.queryByTestId('registrations-pepe')).toBeInTheDocument(); expect(screen.queryByTestId('registrations-pepe')).toBeInTheDocument();
expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument(); expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument();
@ -81,7 +81,7 @@ describe('<LandingPage />', () => {
}, },
}], rootReducer); }], rootReducer);
render(<LandingPage />, null, state); render(<LandingPage />, undefined, state);
expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument(); expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument();
expect(screen.queryByTestId('registrations-pepe')).not.toBeInTheDocument(); expect(screen.queryByTestId('registrations-pepe')).not.toBeInTheDocument();

@ -33,10 +33,12 @@ describe('<Notification />', () => {
describe('grouped notifications', () => { describe('grouped notifications', () => {
it('renders a grouped follow notification for more than 2', async() => { it('renders a grouped follow notification for more than 2', async() => {
const { notification, state } = normalize(require('soapbox/__fixtures__/notification-follow.json')); const { notification, state } = normalize({
const groupedNotification = { ...notification.toJS(), total_count: 5 }; ...require('soapbox/__fixtures__/notification-follow.json'),
total_count: 5,
});
render(<Notification notification={groupedNotification} />, undefined, state); render(<Notification notification={notification} />, undefined, state);
expect(screen.getByTestId('notification')).toBeInTheDocument(); expect(screen.getByTestId('notification')).toBeInTheDocument();
expect(screen.getByTestId('account')).toContainHTML('neko@rdrama.cc'); expect(screen.getByTestId('account')).toContainHTML('neko@rdrama.cc');
@ -44,10 +46,12 @@ describe('<Notification />', () => {
}); });
it('renders a grouped follow notification for 1', async() => { it('renders a grouped follow notification for 1', async() => {
const { notification, state } = normalize(require('soapbox/__fixtures__/notification-follow.json')); const { notification, state } = normalize({
const groupedNotification = { ...notification.toJS(), total_count: 2 }; ...require('soapbox/__fixtures__/notification-follow.json'),
total_count: 2,
});
render(<Notification notification={groupedNotification} />, undefined, state); render(<Notification notification={notification} />, undefined, state);
expect(screen.getByTestId('notification')).toBeInTheDocument(); expect(screen.getByTestId('notification')).toBeInTheDocument();
expect(screen.getByTestId('account')).toContainHTML('neko@rdrama.cc'); expect(screen.getByTestId('account')).toContainHTML('neko@rdrama.cc');

@ -128,16 +128,14 @@ const buildMessage = (
interface INotificaton { interface INotificaton {
hidden?: boolean, hidden?: boolean,
notification: NotificationEntity, notification: NotificationEntity,
onMoveUp: (notificationId: string) => void, onMoveUp?: (notificationId: string) => void,
onMoveDown: (notificationId: string) => void, onMoveDown?: (notificationId: string) => void,
onMention: (account: Account) => void, onMention?: (account: Account) => void,
onFavourite: (status: Status) => void, onFavourite?: (status: Status) => void,
onReblog: (status: Status, e?: KeyboardEvent) => void, onReblog?: (status: Status, e?: KeyboardEvent) => void,
onToggleHidden: (status: Status) => void, onToggleHidden?: (status: Status) => void,
getScrollPosition?: () => ScrollPosition | undefined, getScrollPosition?: () => ScrollPosition | undefined,
updateScrollBottom?: (bottom: number) => void, updateScrollBottom?: (bottom: number) => void,
cacheMediaWidth: () => void,
cachedMediaWidth: number,
siteTitle?: string, siteTitle?: string,
} }
@ -180,35 +178,39 @@ const Notification: React.FC<INotificaton> = (props) => {
const handleMention = (e?: KeyboardEvent) => { const handleMention = (e?: KeyboardEvent) => {
e?.preventDefault(); e?.preventDefault();
if (account && typeof account === 'object') { if (props.onMention && account && typeof account === 'object') {
props.onMention(account); props.onMention(account);
} }
}; };
const handleHotkeyFavourite = (e?: KeyboardEvent) => { const handleHotkeyFavourite = (e?: KeyboardEvent) => {
if (status && typeof status === 'object') { if (props.onFavourite && status && typeof status === 'object') {
props.onFavourite(status); props.onFavourite(status);
} }
}; };
const handleHotkeyBoost = (e?: KeyboardEvent) => { const handleHotkeyBoost = (e?: KeyboardEvent) => {
if (status && typeof status === 'object') { if (props.onReblog && status && typeof status === 'object') {
props.onReblog(status, e); props.onReblog(status, e);
} }
}; };
const handleHotkeyToggleHidden = (e?: KeyboardEvent) => { const handleHotkeyToggleHidden = (e?: KeyboardEvent) => {
if (status && typeof status === 'object') { if (props.onToggleHidden && status && typeof status === 'object') {
props.onToggleHidden(status); props.onToggleHidden(status);
} }
}; };
const handleMoveUp = () => { const handleMoveUp = () => {
onMoveUp(notification.id); if (onMoveUp) {
onMoveUp(notification.id);
}
}; };
const handleMoveDown = () => { const handleMoveDown = () => {
onMoveDown(notification.id); if (onMoveDown) {
onMoveDown(notification.id);
}
}; };
const renderIcon = (): React.ReactNode => { const renderIcon = (): React.ReactNode => {
@ -268,8 +270,6 @@ const Notification: React.FC<INotificaton> = (props) => {
contextType='notifications' contextType='notifications'
getScrollPosition={props.getScrollPosition} getScrollPosition={props.getScrollPosition}
updateScrollBottom={props.updateScrollBottom} updateScrollBottom={props.updateScrollBottom}
cachedMediaWidth={props.cachedMediaWidth}
cacheMediaWidth={props.cacheMediaWidth}
/> />
) : null; ) : null;
default: default:

@ -8,7 +8,7 @@ import { fetchInstance } from 'soapbox/actions/instance';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import SiteLogo from 'soapbox/components/site-logo'; import SiteLogo from 'soapbox/components/site-logo';
import { Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui'; import { Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui';
import { useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks'; import { useAppSelector, useFeatures, useSoapboxConfig, useOwnAccount } from 'soapbox/hooks';
import Sonar from './sonar'; import Sonar from './sonar';
@ -27,6 +27,7 @@ const Header = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const intl = useIntl(); const intl = useIntl();
const account = useOwnAccount();
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true; const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
const { links } = soapboxConfig; const { links } = soapboxConfig;
@ -67,7 +68,7 @@ const Header = () => {
}); });
}; };
if (shouldRedirect) return <Redirect to='/' />; if (account && shouldRedirect) return <Redirect to='/' />;
if (mfaToken) return <Redirect to={`/login?token=${encodeURIComponent(mfaToken)}`} />; if (mfaToken) return <Redirect to={`/login?token=${encodeURIComponent(mfaToken)}`} />;
return ( return (

@ -6,7 +6,7 @@ import { withRouter, RouteComponentProps } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import { HStack, IconButton } from 'soapbox/components/ui'; import { HStack, IconButton, Emoji, Text } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
import { isUserTouching } from 'soapbox/is_mobile'; import { isUserTouching } from 'soapbox/is_mobile';
import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
@ -583,18 +583,35 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
{features.emojiReacts ? ( {features.emojiReacts ? (
<EmojiButtonWrapper statusId={status.id}> <EmojiButtonWrapper statusId={status.id}>
<IconButton {meEmojiReact ? (
className={classNames({ <button
'text-gray-400 hover:text-gray-600': !meEmojiReact, // className copied from IconButton
'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact), // TODO: better abstraction
})} className='flex items-center space-x-2 p-1 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 dark:ring-offset-0 focus:ring-primary-500 bg-white dark:bg-transparent'
title={meEmojiTitle} title={meEmojiTitle}
src={require('@tabler/icons/icons/heart.svg')} >
iconClassName={classNames({ <span className='block w-6 h-6 flex items-center justify-center'>
'fill-accent-300': Boolean(meEmojiReact), <Emoji className='w-full h-full p-0.5' emoji={meEmojiReact} />
})} </span>
text={meEmojiTitle}
/> <Text tag='span' theme='muted' size='sm'>
{meEmojiTitle}
</Text>
</button>
) : (
<IconButton
className={classNames({
'text-gray-400 hover:text-gray-600': !meEmojiReact,
'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact),
})}
title={meEmojiTitle}
src={require('@tabler/icons/icons/heart.svg')}
iconClassName={classNames({
'fill-accent-300': Boolean(meEmojiReact),
})}
text={meEmojiTitle}
/>
)}
</EmojiButtonWrapper> </EmojiButtonWrapper>
) : ( ) : (
<IconButton <IconButton

@ -15,12 +15,12 @@ const TestableComponent = () => (
<Route path='/login' exact><span data-testid='sign-in'>Sign in</span></Route> <Route path='/login' exact><span data-testid='sign-in'>Sign in</span></Route>
{/* WrappedRount will redirect to /login for logged out users... which will resolve to the route above! */} {/* WrappedRount will redirect to /login for logged out users... which will resolve to the route above! */}
<WrappedRoute path='/notifications' /> <WrappedRoute path='/notifications' component={() => null} />
</Switch> </Switch>
); );
describe('<UI />', () => { describe('<UI />', () => {
let store; let store: any;
beforeEach(() => { beforeEach(() => {
store = { store = {

@ -1,17 +1,15 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { Map as ImmutableMap } from 'immutable';
import React from 'react'; import React from 'react';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { MODAL_OPEN } from 'soapbox/actions/modals'; import { MODAL_OPEN } from 'soapbox/actions/modals';
import { mockStore } from 'soapbox/jest/test-helpers'; import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import rootReducer from 'soapbox/reducers';
import ComposeButton from '../compose-button'; import ComposeButton from '../compose-button';
const store = mockStore(rootReducer(ImmutableMap(), {})); const store = mockStore(rootState);
const renderComposeButton = () => { const renderComposeButton = () => {
render( render(
<Provider store={store}> <Provider store={store}>

@ -14,7 +14,7 @@ describe('<CtaBanner />', () => {
it('renders empty', () => { it('renders empty', () => {
const store = { me: true }; const store = { me: true };
render(<CtaBanner />, null, store); render(<CtaBanner />, undefined, store);
expect(screen.queryAllByTestId('cta-banner')).toHaveLength(0); expect(screen.queryAllByTestId('cta-banner')).toHaveLength(0);
}); });
}); });
@ -23,7 +23,7 @@ describe('<CtaBanner />', () => {
it('renders empty', () => { it('renders empty', () => {
const store = { soapbox: ImmutableMap({ singleUserMode: true }) }; const store = { soapbox: ImmutableMap({ singleUserMode: true }) };
render(<CtaBanner />, null, store); render(<CtaBanner />, undefined, store);
expect(screen.queryAllByTestId('cta-banner')).toHaveLength(0); expect(screen.queryAllByTestId('cta-banner')).toHaveLength(0);
}); });
}); });

@ -5,7 +5,9 @@ import { render, screen } from '../../../../jest/test-helpers';
import { normalizeAccount, normalizeRelationship } from '../../../../normalizers'; import { normalizeAccount, normalizeRelationship } from '../../../../normalizers';
import SubscribeButton from '../subscription-button'; import SubscribeButton from '../subscription-button';
let account = { import type { ReducerAccount } from 'soapbox/reducers/accounts';
const justin = {
id: '1', id: '1',
acct: 'justin-username', acct: 'justin-username',
display_name: 'Justin L', display_name: 'Justin L',
@ -13,13 +15,13 @@ let account = {
}; };
describe('<SubscribeButton />', () => { describe('<SubscribeButton />', () => {
let store; let store: any;
describe('with "accountNotifies" disabled', () => { describe('with "accountNotifies" disabled', () => {
it('renders nothing', () => { it('renders nothing', () => {
account = normalizeAccount({ ...account, relationship: normalizeRelationship({ following: true }) }); const account = normalizeAccount({ ...justin, relationship: normalizeRelationship({ following: true }) }) as ReducerAccount;
render(<SubscribeButton account={account} />, null, store); render(<SubscribeButton account={account} />, undefined, store);
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0); expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
}); });
}); });

@ -23,7 +23,7 @@ describe('<TrendsPanel />', () => {
})(), })(),
}; };
render(<TrendsPanel limit={1} />, null, store); render(<TrendsPanel limit={1} />, undefined, store);
expect(screen.getByTestId('hashtag')).toHaveTextContent(/hashtag 1/i); expect(screen.getByTestId('hashtag')).toHaveTextContent(/hashtag 1/i);
expect(screen.getByTestId('hashtag')).toHaveTextContent(/180 people talking/i); expect(screen.getByTestId('hashtag')).toHaveTextContent(/180 people talking/i);
expect(screen.getByTestId('sparklines')).toBeInTheDocument(); expect(screen.getByTestId('sparklines')).toBeInTheDocument();
@ -46,7 +46,7 @@ describe('<TrendsPanel />', () => {
})(), })(),
}; };
render(<TrendsPanel limit={3} />, null, store); render(<TrendsPanel limit={3} />, undefined, store);
expect(screen.queryAllByTestId('hashtag')).toHaveLength(2); expect(screen.queryAllByTestId('hashtag')).toHaveLength(2);
}); });
@ -67,7 +67,7 @@ describe('<TrendsPanel />', () => {
})(), })(),
}; };
render(<TrendsPanel limit={1} />, null, store); render(<TrendsPanel limit={1} />, undefined, store);
expect(screen.queryAllByTestId('hashtag')).toHaveLength(1); expect(screen.queryAllByTestId('hashtag')).toHaveLength(1);
}); });
@ -79,7 +79,7 @@ describe('<TrendsPanel />', () => {
})(), })(),
}; };
render(<TrendsPanel limit={1} />, null, store); render(<TrendsPanel limit={1} />, undefined, store);
expect(screen.queryAllByTestId('hashtag')).toHaveLength(0); expect(screen.queryAllByTestId('hashtag')).toHaveLength(0);
}); });
}); });

@ -24,7 +24,7 @@ describe('<WhoToFollow />', () => {
}, },
}; };
render(<WhoToFollowPanel limit={1} />, null, store); render(<WhoToFollowPanel limit={1} />, undefined, store);
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i); expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
}); });
@ -58,7 +58,7 @@ describe('<WhoToFollow />', () => {
}, },
}; };
render(<WhoToFollowPanel limit={3} />, null, store); render(<WhoToFollowPanel limit={3} />, undefined, store);
expect(screen.queryAllByTestId('account')).toHaveLength(2); expect(screen.queryAllByTestId('account')).toHaveLength(2);
}); });
@ -92,7 +92,7 @@ describe('<WhoToFollow />', () => {
}, },
}; };
render(<WhoToFollowPanel limit={1} />, null, store); render(<WhoToFollowPanel limit={1} />, undefined, store);
expect(screen.queryAllByTestId('account')).toHaveLength(1); expect(screen.queryAllByTestId('account')).toHaveLength(1);
}); });
@ -117,7 +117,7 @@ describe('<WhoToFollow />', () => {
}, },
}; };
render(<WhoToFollowPanel limit={1} />, null, store); render(<WhoToFollowPanel limit={1} />, undefined, store);
expect(screen.queryAllByTestId('account')).toHaveLength(0); expect(screen.queryAllByTestId('account')).toHaveLength(0);
}); });
}); });

@ -9,7 +9,7 @@ import { normalizeAccount, normalizeStatus } from '../../../../../../normalizers
import ReportModal from '../report-modal'; import ReportModal from '../report-modal';
describe('<ReportModal />', () => { describe('<ReportModal />', () => {
let store; let store: any;
beforeEach(() => { beforeEach(() => {
const rules = require('soapbox/__fixtures__/rules.json'); const rules = require('soapbox/__fixtures__/rules.json');

@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { import {
@ -16,6 +16,7 @@ const messages = defineMessages({
subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe to notifications from @{name}' }, subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe to notifications from @{name}' },
unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe to notifications from @{name}' }, unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe to notifications from @{name}' },
subscribeSuccess: { id: 'account.subscribe.success', defaultMessage: 'You have subscribed to this account.' }, subscribeSuccess: { id: 'account.subscribe.success', defaultMessage: 'You have subscribed to this account.' },
subscribeSuccessNotice: { id: 'account.subscribe.successNotice', defaultMessage: 'You have subscribed to this account, but your web notifications are disabled. Please enable them to receive notifications from @{name}.' },
unsubscribeSuccess: { id: 'account.unsubscribe.success', defaultMessage: 'You have unsubscribed from this account.' }, unsubscribeSuccess: { id: 'account.unsubscribe.success', defaultMessage: 'You have unsubscribed from this account.' },
subscribeFailure: { id: 'account.subscribe.failure', defaultMessage: 'An error occurred trying to subscribed to this account.' }, subscribeFailure: { id: 'account.subscribe.failure', defaultMessage: 'An error occurred trying to subscribed to this account.' },
unsubscribeFailure: { id: 'account.unsubscribe.failure', defaultMessage: 'An error occurred trying to unsubscribed to this account.' }, unsubscribeFailure: { id: 'account.unsubscribe.failure', defaultMessage: 'An error occurred trying to unsubscribed to this account.' },
@ -30,6 +31,14 @@ const SubscriptionButton = ({ account }: ISubscriptionButton) => {
const features = useFeatures(); const features = useFeatures();
const intl = useIntl(); const intl = useIntl();
const [hasWebNotificationsEnabled, setWebNotificationsEnabled] = useState<boolean>(true);
const checkWebNotifications = () => {
Notification.requestPermission()
.then((value) => setWebNotificationsEnabled(value === 'granted'))
.catch(() => null);
};
const isFollowing = account.relationship?.following; const isFollowing = account.relationship?.following;
const isRequested = account.relationship?.requested; const isRequested = account.relationship?.requested;
const isSubscribed = features.accountNotifies ? const isSubscribed = features.accountNotifies ?
@ -39,8 +48,13 @@ const SubscriptionButton = ({ account }: ISubscriptionButton) => {
intl.formatMessage(messages.unsubscribe, { name: account.get('username') }) : intl.formatMessage(messages.unsubscribe, { name: account.get('username') }) :
intl.formatMessage(messages.subscribe, { name: account.get('username') }); intl.formatMessage(messages.subscribe, { name: account.get('username') });
const onSubscribeSuccess = () => const onSubscribeSuccess = () => {
dispatch(snackbar.success(intl.formatMessage(messages.subscribeSuccess))); if (hasWebNotificationsEnabled) {
dispatch(snackbar.success(intl.formatMessage(messages.subscribeSuccess)));
} else {
dispatch(snackbar.info(intl.formatMessage(messages.subscribeSuccessNotice, { name: account.get('username') })));
}
};
const onSubscribeFailure = () => const onSubscribeFailure = () =>
dispatch(snackbar.error(intl.formatMessage(messages.subscribeFailure))); dispatch(snackbar.error(intl.formatMessage(messages.subscribeFailure)));
@ -83,6 +97,12 @@ const SubscriptionButton = ({ account }: ISubscriptionButton) => {
} }
}; };
useEffect(() => {
if (features.accountSubscriptions || features.accountNotifies) {
checkWebNotifications();
}
}, []);
if (!features.accountSubscriptions && !features.accountNotifies) { if (!features.accountSubscriptions && !features.accountNotifies) {
return null; return null;
} }
@ -93,7 +113,7 @@ const SubscriptionButton = ({ account }: ISubscriptionButton) => {
src={isSubscribed ? require('@tabler/icons/icons/bell-ringing.svg') : require('@tabler/icons/icons/bell.svg')} src={isSubscribed ? require('@tabler/icons/icons/bell-ringing.svg') : require('@tabler/icons/icons/bell.svg')}
onClick={handleToggle} onClick={handleToggle}
title={title} title={title}
className='text-primary-700 bg-primary-100 dark:!bg-slate-700 dark:!text-white hover:bg-primary-200 p-2' className='text-primary-700 bg-primary-100 dark:!bg-slate-700 dark:!text-white hover:bg-primary-200 disabled:hover:bg-primary-100 p-2'
iconClassName='w-5 h-5' iconClassName='w-5 h-5'
/> />
); );

@ -14,7 +14,7 @@ const TestableComponent = () => (
</Switch> </Switch>
); );
const renderComponent = (store) => render( const renderComponent = (store: any) => render(
<TestableComponent />, <TestableComponent />,
{}, {},
store, store,
@ -22,7 +22,7 @@ const renderComponent = (store) => render(
); );
describe('<Verification />', () => { describe('<Verification />', () => {
let store; let store: any;
beforeEach(() => { beforeEach(() => {
store = { store = {

@ -18,7 +18,7 @@ describe('<Registration />', () => {
mock.onPost('/api/v1/pepe/accounts').reply(200, {}); mock.onPost('/api/v1/pepe/accounts').reply(200, {});
mock.onPost('/api/v1/apps').reply(200, {}); mock.onPost('/api/v1/apps').reply(200, {});
mock.onPost('/oauth/token').reply(200, {}); mock.onPost('/oauth/token').reply(200, {});
mock.onPost('/api/v1/accounts/verify_credentials').reply(200, {}); mock.onGet('/api/v1/accounts/verify_credentials').reply(200, { id: '123' });
mock.onGet('/api/v1/instance').reply(200, {}); mock.onGet('/api/v1/instance').reply(200, {});
}); });
}); });

@ -8,7 +8,7 @@ import { fireEvent, render, screen } from 'soapbox/jest/test-helpers';
import AgeVerification from '../age-verification'; import AgeVerification from '../age-verification';
describe('<AgeVerification />', () => { describe('<AgeVerification />', () => {
let store; let store: any;
beforeEach(() => { beforeEach(() => {
store = { store = {

@ -6,7 +6,7 @@ let listener: ((rect: any) => void) | undefined = undefined;
(window as any).ResizeObserver = class ResizeObserver { (window as any).ResizeObserver = class ResizeObserver {
constructor(ls) { constructor(ls: any) {
listener = ls; listener = ls;
} }
@ -63,7 +63,7 @@ describe('useDimensions()', () => {
disconnect() { disconnect() {
disconnect(); disconnect();
} }
}; };
const { result, unmount } = renderHook(() => useDimensions()); const { result, unmount } = renderHook(() => useDimensions());

@ -1,3 +1,4 @@
import { configureMockStore } from '@jedmao/redux-mock-store';
import { render, RenderOptions } from '@testing-library/react'; import { render, RenderOptions } from '@testing-library/react';
import { merge } from 'immutable'; import { merge } from 'immutable';
import React, { FC, ReactElement } from 'react'; import React, { FC, ReactElement } from 'react';
@ -5,18 +6,19 @@ import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { Action, applyMiddleware, createStore } from 'redux'; import { Action, applyMiddleware, createStore } from 'redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import NotificationsContainer from '../features/ui/containers/notifications_container'; import NotificationsContainer from '../features/ui/containers/notifications_container';
import { default as rootReducer } from '../reducers'; import { default as rootReducer } from '../reducers';
import type { AnyAction } from 'redux';
import type { AppDispatch } from 'soapbox/store';
// Mock Redux // Mock Redux
// https://redux.js.org/recipes/writing-tests/ // https://redux.js.org/recipes/writing-tests/
const middlewares = [thunk]; const rootState = rootReducer(undefined, {} as Action);
const mockStore = configureMockStore(middlewares); const mockStore = configureMockStore<typeof rootState, AnyAction, AppDispatch>([thunk]);
let rootState = rootReducer(undefined, {} as Action);
/** Apply actions to the state, one at a time. */ /** Apply actions to the state, one at a time. */
const applyActions = (state: any, actions: any, reducer: any) => { const applyActions = (state: any, actions: any, reducer: any) => {
@ -26,13 +28,14 @@ const applyActions = (state: any, actions: any, reducer: any) => {
const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk)); const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk));
const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => { const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
let store: any; let store: ReturnType<typeof createTestStore>;
let appState = rootState;
if (storeProps) { if (storeProps) {
rootState = merge(rootState, storeProps); appState = merge(rootState, storeProps);
store = createTestStore(rootState); store = createTestStore(appState);
} else { } else {
store = createTestStore(rootState); store = createTestStore(appState);
} }
const props = { const props = {

@ -47,6 +47,13 @@ describe('normalizeAccount()', () => {
expect(result.birthday).toEqual('1993-07-03'); expect(result.birthday).toEqual('1993-07-03');
}); });
it('normalizes undefined birthday to empty string', () => {
const account = require('soapbox/__fixtures__/mastodon-account.json');
const result = normalizeAccount(account);
expect(result.birthday).toEqual('');
});
it('normalizes Pleroma legacy fields', () => { it('normalizes Pleroma legacy fields', () => {
const account = require('soapbox/__fixtures__/pleroma-2.2.2-account.json'); const account = require('soapbox/__fixtures__/pleroma-2.2.2-account.json');
const result = normalizeAccount(account); const result = normalizeAccount(account);
@ -147,9 +154,9 @@ describe('normalizeAccount()', () => {
const result = normalizeAccount(account); const result = normalizeAccount(account);
const field = result.fields.get(1); const field = result.fields.get(1);
expect(field.name_emojified).toBe('Soapbox <img draggable="false" class="emojione" alt=":ablobcatrainbow:" title=":ablobcatrainbow:" src="https://gleasonator.com/emoji/blobcat/ablobcatrainbow.png" />'); expect(field?.name_emojified).toBe('Soapbox <img draggable="false" class="emojione" alt=":ablobcatrainbow:" title=":ablobcatrainbow:" src="https://gleasonator.com/emoji/blobcat/ablobcatrainbow.png" />');
expect(field.value_emojified).toBe('<a href="https://soapbox.pub" rel="ugc">https://soapbox.pub</a> <img draggable="false" class="emojione" alt=":soapbox:" title=":soapbox:" src="https://gleasonator.com/emoji/Gleasonator/soapbox.png" />'); expect(field?.value_emojified).toBe('<a href="https://soapbox.pub" rel="ugc">https://soapbox.pub</a> <img draggable="false" class="emojione" alt=":soapbox:" title=":soapbox:" src="https://gleasonator.com/emoji/Gleasonator/soapbox.png" />');
expect(field.value_plain).toBe('https://soapbox.pub :soapbox:'); expect(field?.value_plain).toBe('https://soapbox.pub :soapbox:');
}); });
it('adds default avatar and banner to GoToSocial account', () => { it('adds default avatar and banner to GoToSocial account', () => {

@ -38,11 +38,11 @@ describe('normalizePoll()', () => {
const result = normalizePoll(poll); const result = normalizePoll(poll);
// Emojifies poll options // Emojifies poll options
expect(result.options.get(1).title_emojified) expect(result.options.get(1)?.title_emojified)
.toEqual('Custom emoji <img draggable="false" class="emojione" alt=":gleason_excited:" title=":gleason_excited:" src="https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png" /> '); .toEqual('Custom emoji <img draggable="false" class="emojione" alt=":gleason_excited:" title=":gleason_excited:" src="https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png" /> ');
// Parses emojis as Immutable.Record's // Parses emojis as Immutable.Record's
expect(ImmutableRecord.isRecord(result.emojis.get(0))).toBe(true); expect(ImmutableRecord.isRecord(result.emojis.get(0))).toBe(true);
expect(result.emojis.get(1).shortcode).toEqual('soapbox'); expect(result.emojis.get(1)?.shortcode).toEqual('soapbox');
}); });
}); });

@ -2,6 +2,8 @@ import { Record as ImmutableRecord, fromJS } from 'immutable';
import { normalizeStatus } from '../status'; import { normalizeStatus } from '../status';
import type { Poll, Card } from 'soapbox/types/entities';
describe('normalizeStatus()', () => { describe('normalizeStatus()', () => {
it('adds base fields', () => { it('adds base fields', () => {
const status = {}; const status = {};
@ -42,8 +44,8 @@ describe('normalizeStatus()', () => {
const result = normalizeStatus(status).mentions; const result = normalizeStatus(status).mentions;
expect(result.size).toBe(1); expect(result.size).toBe(1);
expect(result.get(0).toJS()).toMatchObject(expected); expect(result.get(0)?.toJS()).toMatchObject(expected);
expect(result.get(0).id).toEqual('106801667066418367'); expect(result.get(0)?.id).toEqual('106801667066418367');
expect(ImmutableRecord.isRecord(result.get(0))).toBe(true); expect(ImmutableRecord.isRecord(result.get(0))).toBe(true);
}); });
@ -101,8 +103,7 @@ describe('normalizeStatus()', () => {
const result = normalizeStatus(status).media_attachments; const result = normalizeStatus(status).media_attachments;
expect(result.size).toBe(4); expect(result.size).toBe(4);
expect(result.get(0).text_url).toBe(undefined); expect(result.get(1)?.meta).toEqual(fromJS({}));
expect(result.get(1).meta).toEqual(fromJS({}));
expect(result.getIn([1, 'pleroma', 'mime_type'])).toBe('application/x-nes-rom'); expect(result.getIn([1, 'pleroma', 'mime_type'])).toBe('application/x-nes-rom');
expect(ImmutableRecord.isRecord(result.get(3))).toBe(true); expect(ImmutableRecord.isRecord(result.get(3))).toBe(true);
}); });
@ -147,6 +148,7 @@ describe('normalizeStatus()', () => {
it('normalizes poll and poll options', () => { it('normalizes poll and poll options', () => {
const status = { poll: { options: [{ title: 'Apples' }] } }; const status = { poll: { options: [{ title: 'Apples' }] } };
const result = normalizeStatus(status); const result = normalizeStatus(status);
const poll = result.poll as Poll;
const expected = { const expected = {
options: [{ title: 'Apples', votes_count: 0 }], options: [{ title: 'Apples', votes_count: 0 }],
@ -159,46 +161,49 @@ describe('normalizeStatus()', () => {
voted: false, voted: false,
}; };
expect(ImmutableRecord.isRecord(result.poll)).toBe(true); expect(ImmutableRecord.isRecord(poll)).toBe(true);
expect(ImmutableRecord.isRecord(result.poll.options.get(0))).toBe(true); expect(ImmutableRecord.isRecord(poll.options.get(0))).toBe(true);
expect(result.poll.toJS()).toMatchObject(expected); expect(poll.toJS()).toMatchObject(expected);
expect(result.poll.expires_at instanceof Date).toBe(true); expect(poll.expires_at instanceof Date).toBe(true);
}); });
it('normalizes a Pleroma logged-out poll', () => { it('normalizes a Pleroma logged-out poll', () => {
const status = require('soapbox/__fixtures__/pleroma-status-with-poll.json'); const status = require('soapbox/__fixtures__/pleroma-status-with-poll.json');
const result = normalizeStatus(status); const result = normalizeStatus(status);
const poll = result.poll as Poll;
// Adds logged-in fields // Adds logged-in fields
expect(result.poll.voted).toBe(false); expect(poll.voted).toBe(false);
expect(result.poll.own_votes).toBe(null); expect(poll.own_votes).toBe(null);
}); });
it('normalizes poll with emojis', () => { it('normalizes poll with emojis', () => {
const status = require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json'); const status = require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json');
const result = normalizeStatus(status); const result = normalizeStatus(status);
const poll = result.poll as Poll;
// Emojifies poll options // Emojifies poll options
expect(result.poll.options.get(1).title_emojified) expect(poll.options.get(1)?.title_emojified)
.toEqual('Custom emoji <img draggable="false" class="emojione" alt=":gleason_excited:" title=":gleason_excited:" src="https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png" /> '); .toEqual('Custom emoji <img draggable="false" class="emojione" alt=":gleason_excited:" title=":gleason_excited:" src="https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png" /> ');
// Parses emojis as Immutable.Record's // Parses emojis as Immutable.Record's
expect(ImmutableRecord.isRecord(result.poll.emojis.get(0))).toBe(true); expect(ImmutableRecord.isRecord(poll.emojis.get(0))).toBe(true);
expect(result.poll.emojis.get(1).shortcode).toEqual('soapbox'); expect(poll.emojis.get(1)?.shortcode).toEqual('soapbox');
}); });
it('normalizes a card', () => { it('normalizes a card', () => {
const status = require('soapbox/__fixtures__/status-with-card.json'); const status = require('soapbox/__fixtures__/status-with-card.json');
const result = normalizeStatus(status); const result = normalizeStatus(status);
const card = result.card as Card;
expect(ImmutableRecord.isRecord(result.card)).toBe(true); expect(ImmutableRecord.isRecord(card)).toBe(true);
expect(result.card.type).toEqual('link'); expect(card.type).toEqual('link');
expect(result.card.provider_url).toEqual('https://soapbox.pub'); expect(card.provider_url).toEqual('https://soapbox.pub');
}); });
it('preserves Truth Social external_video_id', () => { it('preserves Truth Social external_video_id', () => {
const status = require('soapbox/__fixtures__/truthsocial-status-with-external-video.json'); const status = require('soapbox/__fixtures__/truthsocial-status-with-external-video.json');
const result = normalizeStatus(status); const result = normalizeStatus(status);
expect(result.media_attachments.get(0).external_video_id).toBe('vwfnq9'); expect(result.media_attachments.get(0)?.external_video_id).toBe('vwfnq9');
}); });
}); });

@ -24,7 +24,7 @@ export const AccountRecord = ImmutableRecord({
acct: '', acct: '',
avatar: '', avatar: '',
avatar_static: '', avatar_static: '',
birthday: undefined as string | undefined, birthday: '',
bot: false, bot: false,
created_at: new Date(), created_at: new Date(),
discoverable: false, discoverable: false,
@ -261,6 +261,12 @@ const normalizeDiscoverable = (account: ImmutableMap<string, any>) => {
return account.set('discoverable', discoverable); return account.set('discoverable', discoverable);
}; };
/** Normalize undefined/null birthday to empty string. */
const fixBirthday = (account: ImmutableMap<string, any>) => {
const birthday = account.get('birthday');
return account.set('birthday', birthday || '');
};
export const normalizeAccount = (account: Record<string, any>) => { export const normalizeAccount = (account: Record<string, any>) => {
return AccountRecord( return AccountRecord(
ImmutableMap(fromJS(account)).withMutations(account => { ImmutableMap(fromJS(account)).withMutations(account => {
@ -280,6 +286,7 @@ export const normalizeAccount = (account: Record<string, any>) => {
addStaffFields(account); addStaffFields(account);
fixUsername(account); fixUsername(account);
fixDisplayName(account); fixDisplayName(account);
fixBirthday(account);
addInternalFields(account); addInternalFields(account);
}), }),
); );

@ -46,8 +46,18 @@ const normalizeUrls = (attachment: ImmutableMap<string, any>) => {
return attachment.mergeWith(mergeDefined, base); return attachment.mergeWith(mergeDefined, base);
}; };
// Ensure meta is not null
const normalizeMeta = (attachment: ImmutableMap<string, any>) => {
const meta = ImmutableMap().merge(attachment.get('meta'));
return attachment.set('meta', meta);
};
export const normalizeAttachment = (attachment: Record<string, any>) => { export const normalizeAttachment = (attachment: Record<string, any>) => {
return AttachmentRecord( return AttachmentRecord(
normalizeUrls(ImmutableMap(fromJS(attachment))), ImmutableMap(fromJS(attachment)).withMutations((attachment: ImmutableMap<string, any>) => {
normalizeUrls(attachment);
normalizeMeta(attachment);
}),
); );
}; };

@ -32,6 +32,6 @@ describe('normalizeSoapboxConfig()', () => {
const result = normalizeSoapboxConfig(require('soapbox/__fixtures__/spinster-soapbox.json')); const result = normalizeSoapboxConfig(require('soapbox/__fixtures__/spinster-soapbox.json'));
expect(ImmutableRecord.isRecord(result.promoPanel)).toBe(true); expect(ImmutableRecord.isRecord(result.promoPanel)).toBe(true);
expect(ImmutableRecord.isRecord(result.promoPanel.items.get(0))).toBe(true); expect(ImmutableRecord.isRecord(result.promoPanel.items.get(0))).toBe(true);
expect(result.promoPanel.items.get(2).icon).toBe('question-circle'); expect(result.promoPanel.items.get(2)?.icon).toBe('question-circle');
}); });
}); });

@ -23,7 +23,7 @@ describe('accounts reducer', () => {
const action = { type: ACCOUNT_IMPORT, account }; const action = { type: ACCOUNT_IMPORT, account };
const result = reducer(undefined, action).get('106801667066418367'); const result = reducer(undefined, action).get('106801667066418367');
expect(result.moved).toBe('107945464165013501'); expect(result?.moved).toBe('107945464165013501');
}); });
}); });
}); });

@ -11,7 +11,7 @@ import reducer from '../alerts';
describe('alerts reducer', () => { describe('alerts reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableList()); expect(reducer(undefined, {} as any)).toEqual(ImmutableList());
}); });
describe('ALERT_SHOW', () => { describe('ALERT_SHOW', () => {

@ -19,7 +19,7 @@ describe('carousels reducer', () => {
describe('CAROUSEL_AVATAR_REQUEST', () => { describe('CAROUSEL_AVATAR_REQUEST', () => {
it('sets "isLoading" to "true"', () => { it('sets "isLoading" to "true"', () => {
const initialState = { isLoading: false, avatars: [] }; const initialState = { isLoading: false, avatars: [], error: false };
const action = { type: CAROUSEL_AVATAR_REQUEST }; const action = { type: CAROUSEL_AVATAR_REQUEST };
expect(reducer(initialState, action).isLoading).toEqual(true); expect(reducer(initialState, action).isLoading).toEqual(true);
}); });
@ -39,7 +39,7 @@ describe('carousels reducer', () => {
describe('CAROUSEL_AVATAR_FAIL', () => { describe('CAROUSEL_AVATAR_FAIL', () => {
it('sets "isLoading" to "true"', () => { it('sets "isLoading" to "true"', () => {
const initialState = { isLoading: true, avatars: [] }; const initialState = { isLoading: true, avatars: [], error: false };
const action = { type: CAROUSEL_AVATAR_FAIL }; const action = { type: CAROUSEL_AVATAR_FAIL };
const result = reducer(initialState, action); const result = reducer(initialState, action);

@ -200,7 +200,7 @@ describe('compose reducer', () => {
}); });
it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, don\'t toggle if spoiler active', () => { it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, don\'t toggle if spoiler active', () => {
const state = ReducerRecord({ spoiler: true, sensitive: true, idempotencyKey: null }); const state = ReducerRecord({ spoiler: true, sensitive: true, idempotencyKey: '' });
const action = { const action = {
type: actions.COMPOSE_SENSITIVITY_CHANGE, type: actions.COMPOSE_SENSITIVITY_CHANGE,
}; };
@ -297,12 +297,12 @@ describe('compose reducer', () => {
}); });
it('should handle COMPOSE_SUBMIT_SUCCESS', () => { it('should handle COMPOSE_SUBMIT_SUCCESS', () => {
const state = ReducerRecord({ default_privacy: null, privacy: 'public' }); const state = ReducerRecord({ default_privacy: 'public', privacy: 'private' });
const action = { const action = {
type: actions.COMPOSE_SUBMIT_SUCCESS, type: actions.COMPOSE_SUBMIT_SUCCESS,
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS()).toMatchObject({
privacy: null, privacy: 'public',
}); });
}); });

@ -4,6 +4,6 @@ import reducer from '../custom_emojis';
describe('custom_emojis reducer', () => { describe('custom_emojis reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableList()); expect(reducer(undefined, {} as any)).toEqual(ImmutableList());
}); });
}); });

@ -4,7 +4,7 @@ import reducer from '../group_editor';
describe('group_editor reducer', () => { describe('group_editor reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({ expect(reducer(undefined, {} as any)).toEqual(ImmutableMap({
groupId: null, groupId: null,
isSubmitting: false, isSubmitting: false,
isChanged: false, isChanged: false,

@ -4,7 +4,7 @@ import reducer from '../group_lists';
describe('group_lists reducer', () => { describe('group_lists reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({ expect(reducer(undefined, {} as any)).toEqual(ImmutableMap({
featured: ImmutableList(), featured: ImmutableList(),
member: ImmutableList(), member: ImmutableList(),
admin: ImmutableList(), admin: ImmutableList(),

@ -4,6 +4,6 @@ import reducer from '../group_relationships';
describe('group_relationships reducer', () => { describe('group_relationships reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap()); expect(reducer(undefined, {} as any)).toEqual(ImmutableMap());
}); });
}); });

@ -4,6 +4,6 @@ import reducer from '../groups';
describe('groups reducer', () => { describe('groups reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap()); expect(reducer(undefined, {} as any)).toEqual(ImmutableMap());
}); });
}); });

@ -4,7 +4,7 @@ import reducer from '..';
describe('root reducer', () => { describe('root reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
const result = reducer(undefined, {}); const result = reducer(undefined, {} as any);
expect(ImmutableRecord.isRecord(result)).toBe(true); expect(ImmutableRecord.isRecord(result)).toBe(true);
expect(result.accounts.get('')).toBe(undefined); expect(result.accounts.get('')).toBe(undefined);
expect(result.instance.version).toEqual('0.0.0'); expect(result.instance.version).toEqual('0.0.0');

@ -6,7 +6,7 @@ import reducer from '../meta';
describe('meta reducer', () => { describe('meta reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
const result = reducer(undefined, {}); const result = reducer(undefined, {} as any);
expect(ImmutableRecord.isRecord(result)).toBe(true); expect(ImmutableRecord.isRecord(result)).toBe(true);
expect(result.instance_fetch_failed).toBe(false); expect(result.instance_fetch_failed).toBe(false);
expect(result.swUpdating).toBe(false); expect(result.swUpdating).toBe(false);

@ -9,7 +9,7 @@ import reducer from '../mutes';
describe('mutes reducer', () => { describe('mutes reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {}).toJS()).toEqual({ expect(reducer(undefined, {} as any).toJS()).toEqual({
new: { new: {
isSubmitting: false, isSubmitting: false,
accountId: null, accountId: null,

@ -4,7 +4,7 @@ import reducer from '../onboarding';
describe('onboarding reducer', () => { describe('onboarding reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual({ expect(reducer(undefined, {} as any)).toEqual({
needsOnboarding: false, needsOnboarding: false,
}); });
}); });
@ -12,7 +12,7 @@ describe('onboarding reducer', () => {
describe('ONBOARDING_START', () => { describe('ONBOARDING_START', () => {
it('sets "needsOnboarding" to "true"', () => { it('sets "needsOnboarding" to "true"', () => {
const initialState = { needsOnboarding: false }; const initialState = { needsOnboarding: false };
const action = { type: ONBOARDING_START }; const action = { type: ONBOARDING_START } as any;
expect(reducer(initialState, action).needsOnboarding).toEqual(true); expect(reducer(initialState, action).needsOnboarding).toEqual(true);
}); });
}); });
@ -20,7 +20,7 @@ describe('onboarding reducer', () => {
describe('ONBOARDING_END', () => { describe('ONBOARDING_END', () => {
it('sets "needsOnboarding" to "false"', () => { it('sets "needsOnboarding" to "false"', () => {
const initialState = { needsOnboarding: true }; const initialState = { needsOnboarding: true };
const action = { type: ONBOARDING_END }; const action = { type: ONBOARDING_END } as any;
expect(reducer(initialState, action).needsOnboarding).toEqual(false); expect(reducer(initialState, action).needsOnboarding).toEqual(false);
}); });
}); });

@ -15,14 +15,14 @@ describe('rules reducer', () => {
describe('RULES_FETCH_REQUEST', () => { describe('RULES_FETCH_REQUEST', () => {
it('sets "needsOnboarding" to "true"', () => { it('sets "needsOnboarding" to "true"', () => {
const action = { type: RULES_FETCH_REQUEST }; const action = { type: RULES_FETCH_REQUEST } as any;
expect(reducer(initialState, action).isLoading).toEqual(true); expect(reducer(initialState, action).isLoading).toEqual(true);
}); });
}); });
describe('ONBOARDING_END', () => { describe('ONBOARDING_END', () => {
it('sets "needsOnboarding" to "false"', () => { it('sets "needsOnboarding" to "false"', () => {
const action = { type: RULES_FETCH_SUCCESS, payload: [{ id: '123' }] }; const action = { type: RULES_FETCH_SUCCESS, payload: [{ id: '123' }] } as any;
const result = reducer(initialState, action); const result = reducer(initialState, action);
expect(result.isLoading).toEqual(false); expect(result.isLoading).toEqual(false);
expect(result.items[0].id).toEqual('123'); expect(result.items[0].id).toEqual('123');

@ -4,7 +4,7 @@ import reducer from '../settings';
describe('settings reducer', () => { describe('settings reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({ expect(reducer(undefined, {} as any)).toEqual(ImmutableMap({
saved: true, saved: true,
})); }));
}); });

@ -1,7 +1,6 @@
import { import {
Map as ImmutableMap, Map as ImmutableMap,
Record as ImmutableRecord, Record as ImmutableRecord,
fromJS,
} from 'immutable'; } from 'immutable';
import { STATUS_IMPORT } from 'soapbox/actions/importer'; import { STATUS_IMPORT } from 'soapbox/actions/importer';
@ -11,12 +10,13 @@ import {
STATUS_DELETE_REQUEST, STATUS_DELETE_REQUEST,
STATUS_DELETE_FAIL, STATUS_DELETE_FAIL,
} from 'soapbox/actions/statuses'; } from 'soapbox/actions/statuses';
import { normalizeStatus } from 'soapbox/normalizers';
import reducer from '../statuses'; import reducer, { ReducerStatus } from '../statuses';
describe('statuses reducer', () => { describe('statuses reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap()); expect(reducer(undefined, {} as any)).toEqual(ImmutableMap());
}); });
describe('STATUS_IMPORT', () => { describe('STATUS_IMPORT', () => {
@ -35,7 +35,7 @@ describe('statuses reducer', () => {
const expected = ['NEETzsche', 'alex', 'Lumeinshin', 'sneeden']; const expected = ['NEETzsche', 'alex', 'Lumeinshin', 'sneeden'];
const result = reducer(undefined, action) const result = reducer(undefined, action)
.getIn(['AFChectaqZjmOVkXZ2', 'mentions']) .get('AFChectaqZjmOVkXZ2')?.mentions
.map(mention => mention.get('username')) .map(mention => mention.get('username'))
.toJS(); .toJS();
@ -84,19 +84,18 @@ describe('statuses reducer', () => {
remote_url: null, remote_url: null,
}]; }];
expect(state.getIn(['017eeb0e-e5e7-98fe-6b2b-ad02349251fb', 'media_attachments']).toJS()).toMatchObject(expected); expect(state.get('017eeb0e-e5e7-98fe-6b2b-ad02349251fb')?.media_attachments.toJS()).toMatchObject(expected);
}); });
it('fixes Pleroma attachments', () => { it('fixes Pleroma attachments', () => {
const status = require('soapbox/__fixtures__/pleroma-status-with-attachments.json'); const status = require('soapbox/__fixtures__/pleroma-status-with-attachments.json');
const action = { type: STATUS_IMPORT, status }; const action = { type: STATUS_IMPORT, status };
const state = reducer(undefined, action); const state = reducer(undefined, action);
const result = state.get('AGNkA21auFR5lnEAHw').media_attachments; const result = state.get('AGNkA21auFR5lnEAHw')?.media_attachments;
expect(result.size).toBe(4); expect(result?.size).toBe(4);
expect(result.get(0).text_url).toBe(undefined); expect(result?.get(1)?.meta).toEqual(ImmutableMap());
expect(result.get(1).meta).toEqual(ImmutableMap()); expect(result?.getIn([1, 'pleroma', 'mime_type'])).toBe('application/x-nes-rom');
expect(result.getIn([1, 'pleroma', 'mime_type'])).toBe('application/x-nes-rom');
}); });
it('hides CWs', () => { it('hides CWs', () => {
@ -160,7 +159,9 @@ Promoting free speech, even for people and ideas you dislike`;
describe('STATUS_CREATE_REQUEST', () => { describe('STATUS_CREATE_REQUEST', () => {
it('increments the replies_count of its parent', () => { it('increments the replies_count of its parent', () => {
const state = fromJS({ '123': { replies_count: 4 } }); const state = ImmutableMap({
'123': normalizeStatus({ replies_count: 4 }) as ReducerStatus,
});
const action = { const action = {
type: STATUS_CREATE_REQUEST, type: STATUS_CREATE_REQUEST,
@ -174,7 +175,9 @@ Promoting free speech, even for people and ideas you dislike`;
describe('STATUS_CREATE_FAIL', () => { describe('STATUS_CREATE_FAIL', () => {
it('decrements the replies_count of its parent', () => { it('decrements the replies_count of its parent', () => {
const state = fromJS({ '123': { replies_count: 5 } }); const state = ImmutableMap({
'123': normalizeStatus({ replies_count: 5 }) as ReducerStatus,
});
const action = { const action = {
type: STATUS_CREATE_FAIL, type: STATUS_CREATE_FAIL,
@ -188,7 +191,9 @@ Promoting free speech, even for people and ideas you dislike`;
describe('STATUS_DELETE_REQUEST', () => { describe('STATUS_DELETE_REQUEST', () => {
it('decrements the replies_count of its parent', () => { it('decrements the replies_count of its parent', () => {
const state = fromJS({ '123': { replies_count: 4 } }); const state = ImmutableMap({
'123': normalizeStatus({ replies_count: 4 }) as ReducerStatus,
});
const action = { const action = {
type: STATUS_DELETE_REQUEST, type: STATUS_DELETE_REQUEST,
@ -200,7 +205,9 @@ Promoting free speech, even for people and ideas you dislike`;
}); });
it('gracefully does nothing if no parent', () => { it('gracefully does nothing if no parent', () => {
const state = fromJS({ '123': { replies_count: 4 } }); const state = ImmutableMap({
'123': normalizeStatus({ replies_count: 4 }) as ReducerStatus,
});
const action = { const action = {
type: STATUS_DELETE_REQUEST, type: STATUS_DELETE_REQUEST,
@ -214,7 +221,9 @@ Promoting free speech, even for people and ideas you dislike`;
describe('STATUS_DELETE_FAIL', () => { describe('STATUS_DELETE_FAIL', () => {
it('decrements the replies_count of its parent', () => { it('decrements the replies_count of its parent', () => {
const state = fromJS({ '123': { replies_count: 4 } }); const state = ImmutableMap({
'123': normalizeStatus({ replies_count: 4 }) as ReducerStatus,
});
const action = { const action = {
type: STATUS_DELETE_FAIL, type: STATUS_DELETE_FAIL,
@ -226,7 +235,9 @@ Promoting free speech, even for people and ideas you dislike`;
}); });
it('gracefully does nothing if no parent', () => { it('gracefully does nothing if no parent', () => {
const state = fromJS({ '123': { replies_count: 4 } }); const state = ImmutableMap({
'123': normalizeStatus({ replies_count: 4 }) as ReducerStatus,
});
const action = { const action = {
type: STATUS_DELETE_FAIL, type: STATUS_DELETE_FAIL,

@ -2,7 +2,7 @@ import reducer from '../trends';
describe('trends reducer', () => { describe('trends reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {}).toJS()).toEqual({ expect(reducer(undefined, {} as any).toJS()).toEqual({
items: [], items: [],
isLoading: false, isLoading: false,
}); });

@ -10,13 +10,13 @@ import {
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
const EditRecord = ImmutableRecord({ export const EditRecord = ImmutableRecord({
isSubmitting: false, isSubmitting: false,
account: null, account: null as string | null,
comment: '', comment: '',
}); });
const ReducerRecord = ImmutableRecord({ export const ReducerRecord = ImmutableRecord({
edit: EditRecord(), edit: EditRecord(),
}); });

@ -136,7 +136,6 @@ export const StateRecord = ImmutableRecord(
}, {}), }, {}),
); );
// @ts-ignore: This type is fine but TS thinks it's wrong
const appReducer = combineReducers(reducers, StateRecord); const appReducer = combineReducers(reducers, StateRecord);
// Clear the state (mostly) when the user logs out // Clear the state (mostly) when the user logs out

@ -54,7 +54,7 @@ import {
import type { APIEntity } from 'soapbox/types/entities'; import type { APIEntity } from 'soapbox/types/entities';
const ListRecord = ImmutableRecord({ export const ListRecord = ImmutableRecord({
next: null as string | null, next: null as string | null,
items: ImmutableOrderedSet<string>(), items: ImmutableOrderedSet<string>(),
isLoading: false, isLoading: false,
@ -72,7 +72,7 @@ const ReactionListRecord = ImmutableRecord({
isLoading: false, isLoading: false,
}); });
const ReducerRecord = ImmutableRecord({ export const ReducerRecord = ImmutableRecord({
followers: ImmutableMap<string, List>(), followers: ImmutableMap<string, List>(),
following: ImmutableMap<string, List>(), following: ImmutableMap<string, List>(),
reblogged_by: ImmutableMap<string, List>(), reblogged_by: ImmutableMap<string, List>(),
@ -90,7 +90,7 @@ const ReducerRecord = ImmutableRecord({
}); });
type State = ReturnType<typeof ReducerRecord>; type State = ReturnType<typeof ReducerRecord>;
type List = ReturnType<typeof ListRecord>; export type List = ReturnType<typeof ListRecord>;
type Reaction = ReturnType<typeof ReactionRecord>; type Reaction = ReturnType<typeof ReactionRecord>;
type ReactionList = ReturnType<typeof ReactionListRecord>; type ReactionList = ReturnType<typeof ReactionListRecord>;
type Items = ImmutableOrderedSet<string>; type Items = ImmutableOrderedSet<string>;

@ -4,11 +4,13 @@ import {
getDomain, getDomain,
} from '../accounts'; } from '../accounts';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
describe('getDomain', () => { describe('getDomain', () => {
const account = AccountRecord({ const account = AccountRecord({
acct: 'alice', acct: 'alice',
url: 'https://party.com/users/alice', url: 'https://party.com/users/alice',
}); }) as ReducerAccount;
it('returns the domain', () => { it('returns the domain', () => {
expect(getDomain(account)).toEqual('party.com'); expect(getDomain(account)).toEqual('party.com');
}); });

@ -10,28 +10,28 @@ test('isIntegerId()', () => {
expect(isIntegerId('-1764036199')).toBe(true); expect(isIntegerId('-1764036199')).toBe(true);
expect(isIntegerId('106801667066418367')).toBe(true); expect(isIntegerId('106801667066418367')).toBe(true);
expect(isIntegerId('9v5bmRalQvjOy0ECcC')).toBe(false); expect(isIntegerId('9v5bmRalQvjOy0ECcC')).toBe(false);
expect(isIntegerId(null)).toBe(false); expect(isIntegerId(null as any)).toBe(false);
expect(isIntegerId(undefined)).toBe(false); expect(isIntegerId(undefined as any)).toBe(false);
}); });
describe('shortNumberFormat', () => { describe('shortNumberFormat', () => {
test('handles non-numbers', () => { test('handles non-numbers', () => {
render(<div data-testid='num'>{shortNumberFormat('not-number')}</div>, null, null); render(<div data-testid='num'>{shortNumberFormat('not-number')}</div>, undefined, null);
expect(screen.getByTestId('num')).toHaveTextContent('•'); expect(screen.getByTestId('num')).toHaveTextContent('•');
}); });
test('formats numbers under 1,000', () => { test('formats numbers under 1,000', () => {
render(<div data-testid='num'>{shortNumberFormat(555)}</div>, null, null); render(<div data-testid='num'>{shortNumberFormat(555)}</div>, undefined, null);
expect(screen.getByTestId('num')).toHaveTextContent('555'); expect(screen.getByTestId('num')).toHaveTextContent('555');
}); });
test('formats numbers under 1,000,000', () => { test('formats numbers under 1,000,000', () => {
render(<div data-testid='num'>{shortNumberFormat(5555)}</div>, null, null); render(<div data-testid='num'>{shortNumberFormat(5555)}</div>, undefined, null);
expect(screen.getByTestId('num')).toHaveTextContent('5.6K'); expect(screen.getByTestId('num')).toHaveTextContent('5.6K');
}); });
test('formats numbers over 1,000,000', () => { test('formats numbers over 1,000,000', () => {
render(<div data-testid='num'>{shortNumberFormat(5555555)}</div>, null, null); render(<div data-testid='num'>{shortNumberFormat(5555555)}</div>, undefined, null);
expect(screen.getByTestId('num')).toHaveTextContent('5.6M'); expect(screen.getByTestId('num')).toHaveTextContent('5.6M');
}); });
}); });

@ -1,4 +1,3 @@
import { fromJS } from 'immutable';
import { normalizeStatus } from 'soapbox/normalizers/status'; import { normalizeStatus } from 'soapbox/normalizers/status';
@ -7,9 +6,11 @@ import {
defaultMediaVisibility, defaultMediaVisibility,
} from '../status'; } from '../status';
import type { ReducerStatus } from 'soapbox/reducers/statuses';
describe('hasIntegerMediaIds()', () => { describe('hasIntegerMediaIds()', () => {
it('returns true for a Pleroma deleted status', () => { it('returns true for a Pleroma deleted status', () => {
const status = normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))); const status = normalizeStatus(require('soapbox/__fixtures__/pleroma-status-deleted.json')) as ReducerStatus;
expect(hasIntegerMediaIds(status)).toBe(true); expect(hasIntegerMediaIds(status)).toBe(true);
}); });
}); });
@ -20,17 +21,17 @@ describe('defaultMediaVisibility()', () => {
}); });
it('hides sensitive media by default', () => { it('hides sensitive media by default', () => {
const status = normalizeStatus({ sensitive: true }); const status = normalizeStatus({ sensitive: true }) as ReducerStatus;
expect(defaultMediaVisibility(status, 'default')).toBe(false); expect(defaultMediaVisibility(status, 'default')).toBe(false);
}); });
it('hides media when displayMedia is hide_all', () => { it('hides media when displayMedia is hide_all', () => {
const status = normalizeStatus({}); const status = normalizeStatus({}) as ReducerStatus;
expect(defaultMediaVisibility(status, 'hide_all')).toBe(false); expect(defaultMediaVisibility(status, 'hide_all')).toBe(false);
}); });
it('shows sensitive media when displayMedia is show_all', () => { it('shows sensitive media when displayMedia is show_all', () => {
const status = normalizeStatus({ sensitive: true }); const status = normalizeStatus({ sensitive: true }) as ReducerStatus;
expect(defaultMediaVisibility(status, 'show_all')).toBe(true); expect(defaultMediaVisibility(status, 'show_all')).toBe(true);
}); });
}); });

@ -4,7 +4,7 @@ import { toTailwind, fromLegacyColors, expandPalette } from '../tailwind';
describe('toTailwind()', () => { describe('toTailwind()', () => {
it('handles empty Soapbox config', () => { it('handles empty Soapbox config', () => {
const soapboxConfig = ImmutableMap(); const soapboxConfig = ImmutableMap<string, any>();
const result = toTailwind(soapboxConfig); const result = toTailwind(soapboxConfig);
const expected = ImmutableMap({ colors: ImmutableMap() }); const expected = ImmutableMap({ colors: ImmutableMap() });
expect(result).toEqual(expected); expect(result).toEqual(expected);

@ -4,70 +4,72 @@ import { normalizeStatus } from 'soapbox/normalizers/status';
import { shouldFilter } from '../timelines'; import { shouldFilter } from '../timelines';
import type { ReducerStatus } from 'soapbox/reducers/statuses';
describe('shouldFilter', () => { describe('shouldFilter', () => {
it('returns false under normal circumstances', () => { it('returns false under normal circumstances', () => {
const columnSettings = fromJS({}); const columnSettings = fromJS({});
const status = normalizeStatus(fromJS({})); const status = normalizeStatus({}) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(false); expect(shouldFilter(status, columnSettings)).toBe(false);
}); });
it('reblog: returns true when `shows.reblog == false`', () => { it('reblog: returns true when `shows.reblog == false`', () => {
const columnSettings = fromJS({ shows: { reblog: false } }); const columnSettings = fromJS({ shows: { reblog: false } });
const status = normalizeStatus(fromJS({ reblog: {} })); const status = normalizeStatus({ reblog: {} }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(true); expect(shouldFilter(status, columnSettings)).toBe(true);
}); });
it('reblog: returns false when `shows.reblog == true`', () => { it('reblog: returns false when `shows.reblog == true`', () => {
const columnSettings = fromJS({ shows: { reblog: true } }); const columnSettings = fromJS({ shows: { reblog: true } });
const status = normalizeStatus(fromJS({ reblog: {} })); const status = normalizeStatus({ reblog: {} }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(false); expect(shouldFilter(status, columnSettings)).toBe(false);
}); });
it('reply: returns true when `shows.reply == false`', () => { it('reply: returns true when `shows.reply == false`', () => {
const columnSettings = fromJS({ shows: { reply: false } }); const columnSettings = fromJS({ shows: { reply: false } });
const status = normalizeStatus(fromJS({ in_reply_to_id: '1234' })); const status = normalizeStatus({ in_reply_to_id: '1234' }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(true); expect(shouldFilter(status, columnSettings)).toBe(true);
}); });
it('reply: returns false when `shows.reply == true`', () => { it('reply: returns false when `shows.reply == true`', () => {
const columnSettings = fromJS({ shows: { reply: true } }); const columnSettings = fromJS({ shows: { reply: true } });
const status = normalizeStatus(fromJS({ in_reply_to_id: '1234' })); const status = normalizeStatus({ in_reply_to_id: '1234' }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(false); expect(shouldFilter(status, columnSettings)).toBe(false);
}); });
it('direct: returns true when `shows.direct == false`', () => { it('direct: returns true when `shows.direct == false`', () => {
const columnSettings = fromJS({ shows: { direct: false } }); const columnSettings = fromJS({ shows: { direct: false } });
const status = normalizeStatus(fromJS({ visibility: 'direct' })); const status = normalizeStatus({ visibility: 'direct' }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(true); expect(shouldFilter(status, columnSettings)).toBe(true);
}); });
it('direct: returns false when `shows.direct == true`', () => { it('direct: returns false when `shows.direct == true`', () => {
const columnSettings = fromJS({ shows: { direct: true } }); const columnSettings = fromJS({ shows: { direct: true } });
const status = normalizeStatus(fromJS({ visibility: 'direct' })); const status = normalizeStatus({ visibility: 'direct' }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(false); expect(shouldFilter(status, columnSettings)).toBe(false);
}); });
it('direct: returns false for a public post when `shows.direct == false`', () => { it('direct: returns false for a public post when `shows.direct == false`', () => {
const columnSettings = fromJS({ shows: { direct: false } }); const columnSettings = fromJS({ shows: { direct: false } });
const status = normalizeStatus(fromJS({ visibility: 'public' })); const status = normalizeStatus({ visibility: 'public' }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(false); expect(shouldFilter(status, columnSettings)).toBe(false);
}); });
it('multiple settings', () => { it('multiple settings', () => {
const columnSettings = fromJS({ shows: { reblog: false, reply: false, direct: false } }); const columnSettings = fromJS({ shows: { reblog: false, reply: false, direct: false } });
const status = normalizeStatus(fromJS({ reblog: null, in_reply_to_id: null, visibility: 'direct' })); const status = normalizeStatus({ reblog: null, in_reply_to_id: null, visibility: 'direct' }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(true); expect(shouldFilter(status, columnSettings)).toBe(true);
}); });
it('multiple settings', () => { it('multiple settings', () => {
const columnSettings = fromJS({ shows: { reblog: false, reply: true, direct: false } }); const columnSettings = fromJS({ shows: { reblog: false, reply: true, direct: false } });
const status = normalizeStatus(fromJS({ reblog: null, in_reply_to_id: '1234', visibility: 'public' })); const status = normalizeStatus({ reblog: null, in_reply_to_id: '1234', visibility: 'public' }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(false); expect(shouldFilter(status, columnSettings)).toBe(false);
}); });
it('multiple settings', () => { it('multiple settings', () => {
const columnSettings = fromJS({ shows: { reblog: true, reply: false, direct: true } }); const columnSettings = fromJS({ shows: { reblog: true, reply: false, direct: true } });
const status = normalizeStatus(fromJS({ reblog: {}, in_reply_to_id: '1234', visibility: 'direct' })); const status = normalizeStatus({ reblog: {}, in_reply_to_id: '1234', visibility: 'direct' }) as ReducerStatus;
expect(shouldFilter(status, columnSettings)).toBe(true); expect(shouldFilter(status, columnSettings)).toBe(true);
}); });
}); });

@ -153,9 +153,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see POST /api/v1/accounts * @see POST /api/v1/accounts
* @see PATCH /api/v1/accounts/update_credentials * @see PATCH /api/v1/accounts/update_credentials
*/ */
// birthdays: v.software === PLEROMA && gte(v.version, '2.4.50'), birthdays: v.software === PLEROMA && v.build === SOAPBOX && gte(v.version, '2.4.50'),
// FIXME: temporarily disabled until they can be deleted on the backend.
birthdays: false,
/** Whether people who blocked you are visible through the API. */ /** Whether people who blocked you are visible through the API. */
blockersVisible: features.includes('blockers_visible'), blockersVisible: features.includes('blockers_visible'),

@ -52,7 +52,7 @@ $fluid-breakpoint: $maximum-width + 20px;
.container { .container {
width: 100%; width: 100%;
max-width: 1440px; max-width: 1280px;
@media screen and (max-width: $no-gap-breakpoint) { @media screen and (max-width: $no-gap-breakpoint) {
padding: 0; padding: 0;
@ -82,7 +82,7 @@ $fluid-breakpoint: $maximum-width + 20px;
.header-container { .header-container {
display: flex; display: flex;
width: 1440px; width: 1280px;
align-items: stretch; align-items: stretch;
justify-content: center; justify-content: center;
flex-wrap: nowrap; flex-wrap: nowrap;

@ -15,7 +15,7 @@
.footer-container { .footer-container {
display: flex; display: flex;
width: 1440px; width: 1280px;
align-items: center; align-items: center;
padding: 0 20px; padding: 0 20px;
flex-direction: column-reverse; flex-direction: column-reverse;

@ -91,7 +91,7 @@
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"array-includes": "^3.1.5", "array-includes": "^3.1.5",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"axios": "^0.27.2", "axios": "^1.0.0-alpha.1",
"axios-mock-adapter": "^1.21.1", "axios-mock-adapter": "^1.21.1",
"babel-loader": "^8.2.5", "babel-loader": "^8.2.5",
"babel-plugin-lodash": "^3.3.4", "babel-plugin-lodash": "^3.3.4",
@ -167,6 +167,7 @@
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"react-redux": "^7.2.5", "react-redux": "^7.2.5",
"react-router-dom": "^5.3.0", "react-router-dom": "^5.3.0",
"react-router-scroll-4": "^1.0.0-beta.2",
"react-simple-pull-to-refresh": "^1.3.0", "react-simple-pull-to-refresh": "^1.3.0",
"react-sparklines": "^1.7.0", "react-sparklines": "^1.7.0",
"react-sticky-box": "^1.0.2", "react-sticky-box": "^1.0.2",
@ -201,6 +202,7 @@
"wicg-inert": "^3.1.1" "wicg-inert": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {
"@jedmao/redux-mock-store": "^3.0.5",
"@testing-library/jest-dom": "^5.16.4", "@testing-library/jest-dom": "^5.16.4",
"@testing-library/react-hooks": "^8.0.1", "@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.0.3", "@testing-library/user-event": "^14.0.3",
@ -224,7 +226,6 @@
"lint-staged": ">=10", "lint-staged": ">=10",
"raf": "^3.4.1", "raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3", "react-intl-translations-manager": "^5.0.3",
"redux-mock-store": "^1.5.4",
"stylelint": "^13.7.2", "stylelint": "^13.7.2",
"stylelint-config-standard": "^22.0.0", "stylelint-config-standard": "^22.0.0",
"stylelint-scss": "^3.18.0", "stylelint-scss": "^3.18.0",

@ -8,7 +8,7 @@ module.exports = {
sm: '581px', sm: '581px',
md: '768px', md: '768px',
lg: '976px', lg: '976px',
xl: '1440px', xl: '1280px',
}, },
extend: { extend: {
fontSize: { fontSize: {

@ -14,6 +14,5 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"esModuleInterop": true, "esModuleInterop": true,
"typeRoots": [ "./types", "./node_modules/@types"] "typeRoots": [ "./types", "./node_modules/@types"]
}, }
"exclude": ["node_modules", "types", "**/*.test.*", "**/__mocks__/*", "**/__tests__/*"]
} }

@ -13,5 +13,5 @@ declare module 'redux-immutable' {
export function combineReducers<S, A extends Action, T>(reducers: ReducersMapObject<S, A>, getDefaultState?: () => Collection.Keyed<T, S>): Reducer<S, A>; export function combineReducers<S, A extends Action, T>(reducers: ReducersMapObject<S, A>, getDefaultState?: () => Collection.Keyed<T, S>): Reducer<S, A>;
export function combineReducers<S, A extends Action>(reducers: ReducersMapObject<S, A>, getDefaultState?: () => Collection.Indexed<S>): Reducer<S, A>; export function combineReducers<S, A extends Action>(reducers: ReducersMapObject<S, A>, getDefaultState?: () => Collection.Indexed<S>): Reducer<S, A>;
export function combineReducers<S>(reducers: ReducersMapObject<S, any>, getDefaultState?: () => Collection.Indexed<S>): Reducer<S>; export function combineReducers<S>(reducers: ReducersMapObject<S, any>, getDefaultState?: () => Collection.Indexed<S>): Reducer<S>;
export function combineReducers<S, T extends object>(reducers: ReducersMapObject<S, any>, getDefaultState?: Record.Factory<T>): Reducer<S>; export function combineReducers<S extends object, T extends object>(reducers: ReducersMapObject<S, any>, getDefaultState?: Record.Factory<T>): Reducer<ReturnType<Record.Factory<S>>>;
} }

@ -1603,6 +1603,11 @@
resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98"
integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==
"@jedmao/redux-mock-store@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@jedmao/redux-mock-store/-/redux-mock-store-3.0.5.tgz#015fa4fc96bfc02b61ca221d9ea0476b78c70c97"
integrity sha512-zNcVCd5/ekSMdQWk64CqTPM24D9Lo59st9KvS+fljGpQXV4SliB7Vo0NFQIgvQJWPYeeobdngnrGy0XbCaARNw==
"@jest/console@^27.5.1": "@jest/console@^27.5.1":
version "27.5.1" version "27.5.1"
resolved "https://registry.yarnpkg.com/@jest/console/-/console-27.5.1.tgz#260fe7239602fe5130a94f1aa386eff54b014bba" resolved "https://registry.yarnpkg.com/@jest/console/-/console-27.5.1.tgz#260fe7239602fe5130a94f1aa386eff54b014bba"
@ -3506,13 +3511,14 @@ axios-mock-adapter@^1.21.1:
fast-deep-equal "^3.1.3" fast-deep-equal "^3.1.3"
is-buffer "^2.0.5" is-buffer "^2.0.5"
axios@^0.27.2: axios@^1.0.0-alpha.1:
version "0.27.2" version "1.0.0-alpha.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" resolved "https://registry.yarnpkg.com/axios/-/axios-1.0.0-alpha.1.tgz#ce69c17ca7605d01787ca754dd906e6fccdf71ee"
integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== integrity sha512-p+meG161943WT+K7sJYquHR46xxi/z0tk7vnSmEf/LrfEAyiP+0uTMMYk1OEo1IRF18oGRhnFxN1y8fLcXaTMw==
dependencies: dependencies:
follow-redirects "^1.14.9" follow-redirects "^1.15.0"
form-data "^4.0.0" form-data "^4.0.0"
proxy-from-env "^1.1.0"
axobject-query@^2.2.0: axobject-query@^2.2.0:
version "2.2.0" version "2.2.0"
@ -5827,10 +5833,10 @@ follow-redirects@^1.0.0:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379"
integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g== integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==
follow-redirects@^1.14.9: follow-redirects@^1.15.0:
version "1.14.9" version "1.15.1"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5"
integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w== integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==
foreach@^2.0.5: foreach@^2.0.5:
version "2.0.5" version "2.0.5"
@ -6667,7 +6673,7 @@ intl@^1.2.5:
resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde" resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde"
integrity sha1-giRKIZDE5Bn4Nx9ao02qNCDiq94= integrity sha1-giRKIZDE5Bn4Nx9ao02qNCDiq94=
invariant@^2.2.2: invariant@^2.2.2, invariant@^2.2.4:
version "2.2.4" version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
@ -9501,6 +9507,11 @@ proxy-addr@~2.0.7:
forwarded "0.2.0" forwarded "0.2.0"
ipaddr.js "1.9.1" ipaddr.js "1.9.1"
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
psl@^1.1.33: psl@^1.1.33:
version "1.8.0" version "1.8.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
@ -9811,6 +9822,14 @@ react-router-dom@^5.3.0:
tiny-invariant "^1.0.2" tiny-invariant "^1.0.2"
tiny-warning "^1.0.0" tiny-warning "^1.0.0"
react-router-scroll-4@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/react-router-scroll-4/-/react-router-scroll-4-1.0.0-beta.2.tgz#d887063ec0f66124aaf450158dd158ff7d3dc279"
integrity sha512-K67Dnm75naSBs/WYc2CDNxqU+eE8iA3I0wSCArgGSHb0xR/7AUcgUEXtCxrQYVTogXvjVK60gmwYvOyRQ6fuBA==
dependencies:
scroll-behavior "^0.9.1"
warning "^3.0.0"
react-router@5.2.1: react-router@5.2.1:
version "5.2.1" version "5.2.1"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.1.tgz#4d2e4e9d5ae9425091845b8dbc6d9d276239774d" resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.1.tgz#4d2e4e9d5ae9425091845b8dbc6d9d276239774d"
@ -10014,13 +10033,6 @@ redux-immutable@^4.0.0:
resolved "https://registry.yarnpkg.com/redux-immutable/-/redux-immutable-4.0.0.tgz#3a1a32df66366462b63691f0e1dc35e472bbc9f3" resolved "https://registry.yarnpkg.com/redux-immutable/-/redux-immutable-4.0.0.tgz#3a1a32df66366462b63691f0e1dc35e472bbc9f3"
integrity sha1-Ohoy32Y2ZGK2NpHw4dw15HK7yfM= integrity sha1-Ohoy32Y2ZGK2NpHw4dw15HK7yfM=
redux-mock-store@^1.5.4:
version "1.5.4"
resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.4.tgz#90d02495fd918ddbaa96b83aef626287c9ab5872"
integrity sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==
dependencies:
lodash.isplainobject "^4.0.6"
redux-thunk@^2.2.0: redux-thunk@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622"
@ -10427,6 +10439,14 @@ schema-utils@^4.0.0:
ajv-formats "^2.1.1" ajv-formats "^2.1.1"
ajv-keywords "^5.0.0" ajv-keywords "^5.0.0"
scroll-behavior@^0.9.1:
version "0.9.12"
resolved "https://registry.yarnpkg.com/scroll-behavior/-/scroll-behavior-0.9.12.tgz#1c22d273ec4ce6cd4714a443fead50227da9424c"
integrity sha512-18sirtyq1P/VsBX6O/vgw20Np+ngduFXEMO4/NDFXabdOKBL2kjPVUpz1y0+jm99EWwFJafxf5/tCyMeXt9Xyg==
dependencies:
dom-helpers "^3.4.0"
invariant "^2.2.4"
select-hose@^2.0.0: select-hose@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"

Loading…
Cancel
Save