Merge remote-tracking branch 'origin/develop' into chats

environments/review-chats-g56n7m/deployments/1301
Chewbacca 2 years ago
commit acede4b519

@ -1,5 +1,5 @@
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
@ -359,14 +359,30 @@ const unblockAccountFail = (error: AxiosError) => ({
error, error,
}); });
const muteAccount = (id: string, notifications?: boolean) => const muteAccount = (id: string, notifications?: boolean, duration = 0) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null; if (!isLoggedIn(getState)) return null;
dispatch(muteAccountRequest(id)); dispatch(muteAccountRequest(id));
const params: Record<string, any> = {
notifications,
};
if (duration) {
const state = getState();
const instance = state.instance;
const v = parseVersion(instance.version);
if (v.software === PLEROMA) {
params.expires_in = duration;
} else {
params.duration = duration;
}
}
return api(getState) return api(getState)
.post(`/api/v1/accounts/${id}/mute`, { notifications }) .post(`/api/v1/accounts/${id}/mute`, params)
.then(response => { .then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
return dispatch(muteAccountSuccess(response.data, getState().statuses)); return dispatch(muteAccountSuccess(response.data, getState().statuses));

@ -5,7 +5,7 @@ import { httpErrorMessages } from 'soapbox/utils/errors';
import type { SnackbarActionSeverity } from './snackbar'; import type { SnackbarActionSeverity } from './snackbar';
import type { AnyAction } from '@reduxjs/toolkit'; import type { AnyAction } from '@reduxjs/toolkit';
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
import type { NotificationObject } from 'soapbox/react-notification'; import type { NotificationObject } from 'react-notification';
const messages = defineMessages({ const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },

@ -1,143 +0,0 @@
import { isLoggedIn } from 'soapbox/utils/auth';
import api from '../api';
import type { AxiosError } from 'axios';
import type { History } from 'history';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL';
const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST';
const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS';
const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL';
const GROUP_EDITOR_VALUE_CHANGE = 'GROUP_EDITOR_VALUE_CHANGE';
const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
const GROUP_EDITOR_SETUP = 'GROUP_EDITOR_SETUP';
const submit = (routerHistory: History) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const groupId = getState().group_editor.get('groupId') as string;
const title = getState().group_editor.get('title') as string;
const description = getState().group_editor.get('description') as string;
const coverImage = getState().group_editor.get('coverImage') as any;
if (groupId === null) {
dispatch(create(title, description, coverImage, routerHistory));
} else {
dispatch(update(groupId, title, description, coverImage, routerHistory));
}
};
const create = (title: string, description: string, coverImage: File, routerHistory: History) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(createRequest());
const formData = new FormData();
formData.append('title', title);
formData.append('description', description);
if (coverImage !== null) {
formData.append('cover_image', coverImage);
}
api(getState).post('/api/v1/groups', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => {
dispatch(createSuccess(data));
routerHistory.push(`/groups/${data.id}`);
}).catch(err => dispatch(createFail(err)));
};
const createRequest = (id?: string) => ({
type: GROUP_CREATE_REQUEST,
id,
});
const createSuccess = (group: APIEntity) => ({
type: GROUP_CREATE_SUCCESS,
group,
});
const createFail = (error: AxiosError) => ({
type: GROUP_CREATE_FAIL,
error,
});
const update = (groupId: string, title: string, description: string, coverImage: File, routerHistory: History) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(updateRequest(groupId));
const formData = new FormData();
formData.append('title', title);
formData.append('description', description);
if (coverImage !== null) {
formData.append('cover_image', coverImage);
}
api(getState).put(`/api/v1/groups/${groupId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => {
dispatch(updateSuccess(data));
routerHistory.push(`/groups/${data.id}`);
}).catch(err => dispatch(updateFail(err)));
};
const updateRequest = (id: string) => ({
type: GROUP_UPDATE_REQUEST,
id,
});
const updateSuccess = (group: APIEntity) => ({
type: GROUP_UPDATE_SUCCESS,
group,
});
const updateFail = (error: AxiosError) => ({
type: GROUP_UPDATE_FAIL,
error,
});
const changeValue = (field: string, value: string | File) => ({
type: GROUP_EDITOR_VALUE_CHANGE,
field,
value,
});
const reset = () => ({
type: GROUP_EDITOR_RESET,
});
const setUp = (group: string) => ({
type: GROUP_EDITOR_SETUP,
group,
});
export {
GROUP_CREATE_REQUEST,
GROUP_CREATE_SUCCESS,
GROUP_CREATE_FAIL,
GROUP_UPDATE_REQUEST,
GROUP_UPDATE_SUCCESS,
GROUP_UPDATE_FAIL,
GROUP_EDITOR_VALUE_CHANGE,
GROUP_EDITOR_RESET,
GROUP_EDITOR_SETUP,
submit,
create,
createRequest,
createSuccess,
createFail,
update,
updateRequest,
updateSuccess,
updateFail,
changeValue,
reset,
setUp,
};

@ -1,550 +0,0 @@
import { AxiosError } from 'axios';
import { isLoggedIn } from 'soapbox/utils/auth';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
const GROUP_FETCH_REQUEST = 'GROUP_FETCH_REQUEST';
const GROUP_FETCH_SUCCESS = 'GROUP_FETCH_SUCCESS';
const GROUP_FETCH_FAIL = 'GROUP_FETCH_FAIL';
const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST';
const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS';
const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL';
const GROUPS_FETCH_REQUEST = 'GROUPS_FETCH_REQUEST';
const GROUPS_FETCH_SUCCESS = 'GROUPS_FETCH_SUCCESS';
const GROUPS_FETCH_FAIL = 'GROUPS_FETCH_FAIL';
const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST';
const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS';
const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL';
const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST';
const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS';
const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL';
const GROUP_MEMBERS_FETCH_REQUEST = 'GROUP_MEMBERS_FETCH_REQUEST';
const GROUP_MEMBERS_FETCH_SUCCESS = 'GROUP_MEMBERS_FETCH_SUCCESS';
const GROUP_MEMBERS_FETCH_FAIL = 'GROUP_MEMBERS_FETCH_FAIL';
const GROUP_MEMBERS_EXPAND_REQUEST = 'GROUP_MEMBERS_EXPAND_REQUEST';
const GROUP_MEMBERS_EXPAND_SUCCESS = 'GROUP_MEMBERS_EXPAND_SUCCESS';
const GROUP_MEMBERS_EXPAND_FAIL = 'GROUP_MEMBERS_EXPAND_FAIL';
const GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST = 'GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST';
const GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS';
const GROUP_REMOVED_ACCOUNTS_FETCH_FAIL = 'GROUP_REMOVED_ACCOUNTS_FETCH_FAIL';
const GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST = 'GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST';
const GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS';
const GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL = 'GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL';
const GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST';
const GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS';
const GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL = 'GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL';
const GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST';
const GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS';
const GROUP_REMOVED_ACCOUNTS_CREATE_FAIL = 'GROUP_REMOVED_ACCOUNTS_CREATE_FAIL';
const GROUP_REMOVE_STATUS_REQUEST = 'GROUP_REMOVE_STATUS_REQUEST';
const GROUP_REMOVE_STATUS_SUCCESS = 'GROUP_REMOVE_STATUS_SUCCESS';
const GROUP_REMOVE_STATUS_FAIL = 'GROUP_REMOVE_STATUS_FAIL';
const fetchGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(fetchGroupRelationships([id]));
if (getState().groups.get(id)) {
return;
}
dispatch(fetchGroupRequest(id));
api(getState).get(`/api/v1/groups/${id}`)
.then(({ data }) => dispatch(fetchGroupSuccess(data)))
.catch(err => dispatch(fetchGroupFail(id, err)));
};
const fetchGroupRequest = (id: string) => ({
type: GROUP_FETCH_REQUEST,
id,
});
const fetchGroupSuccess = (group: APIEntity) => ({
type: GROUP_FETCH_SUCCESS,
group,
});
const fetchGroupFail = (id: string, error: AxiosError) => ({
type: GROUP_FETCH_FAIL,
id,
error,
});
const fetchGroupRelationships = (groupIds: string[]) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const loadedRelationships = getState().group_relationships;
const newGroupIds = groupIds.filter(id => loadedRelationships.get(id, null) === null);
if (newGroupIds.length === 0) {
return;
}
dispatch(fetchGroupRelationshipsRequest(newGroupIds));
api(getState).get(`/api/v1/groups/${newGroupIds[0]}/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
dispatch(fetchGroupRelationshipsSuccess(response.data));
}).catch(error => {
dispatch(fetchGroupRelationshipsFail(error));
});
};
const fetchGroupRelationshipsRequest = (ids: string[]) => ({
type: GROUP_RELATIONSHIPS_FETCH_REQUEST,
ids,
skipLoading: true,
});
const fetchGroupRelationshipsSuccess = (relationships: APIEntity[]) => ({
type: GROUP_RELATIONSHIPS_FETCH_SUCCESS,
relationships,
skipLoading: true,
});
const fetchGroupRelationshipsFail = (error: AxiosError) => ({
type: GROUP_RELATIONSHIPS_FETCH_FAIL,
error,
skipLoading: true,
});
const fetchGroups = (tab: string) => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(fetchGroupsRequest());
api(getState).get('/api/v1/groups?tab=' + tab)
.then(({ data }) => {
dispatch(fetchGroupsSuccess(data, tab));
dispatch(fetchGroupRelationships(data.map((item: APIEntity) => item.id)));
})
.catch(err => dispatch(fetchGroupsFail(err)));
};
const fetchGroupsRequest = () => ({
type: GROUPS_FETCH_REQUEST,
});
const fetchGroupsSuccess = (groups: APIEntity[], tab: string) => ({
type: GROUPS_FETCH_SUCCESS,
groups,
tab,
});
const fetchGroupsFail = (error: AxiosError) => ({
type: GROUPS_FETCH_FAIL,
error,
});
const joinGroup = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(joinGroupRequest(id));
api(getState).post(`/api/v1/groups/${id}/accounts`).then(response => {
dispatch(joinGroupSuccess(response.data));
}).catch(error => {
dispatch(joinGroupFail(id, error));
});
};
const leaveGroup = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(leaveGroupRequest(id));
api(getState).delete(`/api/v1/groups/${id}/accounts`).then(response => {
dispatch(leaveGroupSuccess(response.data));
}).catch(error => {
dispatch(leaveGroupFail(id, error));
});
};
const joinGroupRequest = (id: string) => ({
type: GROUP_JOIN_REQUEST,
id,
});
const joinGroupSuccess = (relationship: APIEntity) => ({
type: GROUP_JOIN_SUCCESS,
relationship,
});
const joinGroupFail = (id: string, error: AxiosError) => ({
type: GROUP_JOIN_FAIL,
id,
error,
});
const leaveGroupRequest = (id: string) => ({
type: GROUP_LEAVE_REQUEST,
id,
});
const leaveGroupSuccess = (relationship: APIEntity) => ({
type: GROUP_LEAVE_SUCCESS,
relationship,
});
const leaveGroupFail = (id: string, error: AxiosError) => ({
type: GROUP_LEAVE_FAIL,
id,
error,
});
const fetchMembers = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(fetchMembersRequest(id));
api(getState).get(`/api/v1/groups/${id}/accounts`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchMembersSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
}).catch(error => {
dispatch(fetchMembersFail(id, error));
});
};
const fetchMembersRequest = (id: string) => ({
type: GROUP_MEMBERS_FETCH_REQUEST,
id,
});
const fetchMembersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: GROUP_MEMBERS_FETCH_SUCCESS,
id,
accounts,
next,
});
const fetchMembersFail = (id: string, error: AxiosError) => ({
type: GROUP_MEMBERS_FETCH_FAIL,
id,
error,
});
const expandMembers = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const url = getState().user_lists.groups.get(id)!.next;
if (url === null) {
return;
}
dispatch(expandMembersRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandMembersSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
}).catch(error => {
dispatch(expandMembersFail(id, error));
});
};
const expandMembersRequest = (id: string) => ({
type: GROUP_MEMBERS_EXPAND_REQUEST,
id,
});
const expandMembersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: GROUP_MEMBERS_EXPAND_SUCCESS,
id,
accounts,
next,
});
const expandMembersFail = (id: string, error: AxiosError) => ({
type: GROUP_MEMBERS_EXPAND_FAIL,
id,
error,
});
const fetchRemovedAccounts = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(fetchRemovedAccountsRequest(id));
api(getState).get(`/api/v1/groups/${id}/removed_accounts`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchRemovedAccountsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
}).catch(error => {
dispatch(fetchRemovedAccountsFail(id, error));
});
};
const fetchRemovedAccountsRequest = (id: string) => ({
type: GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST,
id,
});
const fetchRemovedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS,
id,
accounts,
next,
});
const fetchRemovedAccountsFail = (id: string, error: AxiosError) => ({
type: GROUP_REMOVED_ACCOUNTS_FETCH_FAIL,
id,
error,
});
const expandRemovedAccounts = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const url = getState().user_lists.groups_removed_accounts.get(id)!.next;
if (url === null) {
return;
}
dispatch(expandRemovedAccountsRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandRemovedAccountsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
}).catch(error => {
dispatch(expandRemovedAccountsFail(id, error));
});
};
const expandRemovedAccountsRequest = (id: string) => ({
type: GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST,
id,
});
const expandRemovedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS,
id,
accounts,
next,
});
const expandRemovedAccountsFail = (id: string, error: AxiosError) => ({
type: GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL,
id,
error,
});
const removeRemovedAccount = (groupId: string, id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(removeRemovedAccountRequest(groupId, id));
api(getState).delete(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => {
dispatch(removeRemovedAccountSuccess(groupId, id));
}).catch(error => {
dispatch(removeRemovedAccountFail(groupId, id, error));
});
};
const removeRemovedAccountRequest = (groupId: string, id: string) => ({
type: GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST,
groupId,
id,
});
const removeRemovedAccountSuccess = (groupId: string, id: string) => ({
type: GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS,
groupId,
id,
});
const removeRemovedAccountFail = (groupId: string, id: string, error: AxiosError) => ({
type: GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL,
groupId,
id,
error,
});
const createRemovedAccount = (groupId: string, id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(createRemovedAccountRequest(groupId, id));
api(getState).post(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => {
dispatch(createRemovedAccountSuccess(groupId, id));
}).catch(error => {
dispatch(createRemovedAccountFail(groupId, id, error));
});
};
const createRemovedAccountRequest = (groupId: string, id: string) => ({
type: GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST,
groupId,
id,
});
const createRemovedAccountSuccess = (groupId: string, id: string) => ({
type: GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS,
groupId,
id,
});
const createRemovedAccountFail = (groupId: string, id: string, error: AxiosError) => ({
type: GROUP_REMOVED_ACCOUNTS_CREATE_FAIL,
groupId,
id,
error,
});
const groupRemoveStatus = (groupId: string, id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(groupRemoveStatusRequest(groupId, id));
api(getState).delete(`/api/v1/groups/${groupId}/statuses/${id}`).then(response => {
dispatch(groupRemoveStatusSuccess(groupId, id));
}).catch(error => {
dispatch(groupRemoveStatusFail(groupId, id, error));
});
};
const groupRemoveStatusRequest = (groupId: string, id: string) => ({
type: GROUP_REMOVE_STATUS_REQUEST,
groupId,
id,
});
const groupRemoveStatusSuccess = (groupId: string, id: string) => ({
type: GROUP_REMOVE_STATUS_SUCCESS,
groupId,
id,
});
const groupRemoveStatusFail = (groupId: string, id: string, error: AxiosError) => ({
type: GROUP_REMOVE_STATUS_FAIL,
groupId,
id,
error,
});
export {
GROUP_FETCH_REQUEST,
GROUP_FETCH_SUCCESS,
GROUP_FETCH_FAIL,
GROUP_RELATIONSHIPS_FETCH_REQUEST,
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
GROUP_RELATIONSHIPS_FETCH_FAIL,
GROUPS_FETCH_REQUEST,
GROUPS_FETCH_SUCCESS,
GROUPS_FETCH_FAIL,
GROUP_JOIN_REQUEST,
GROUP_JOIN_SUCCESS,
GROUP_JOIN_FAIL,
GROUP_LEAVE_REQUEST,
GROUP_LEAVE_SUCCESS,
GROUP_LEAVE_FAIL,
GROUP_MEMBERS_FETCH_REQUEST,
GROUP_MEMBERS_FETCH_SUCCESS,
GROUP_MEMBERS_FETCH_FAIL,
GROUP_MEMBERS_EXPAND_REQUEST,
GROUP_MEMBERS_EXPAND_SUCCESS,
GROUP_MEMBERS_EXPAND_FAIL,
GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST,
GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS,
GROUP_REMOVED_ACCOUNTS_FETCH_FAIL,
GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST,
GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS,
GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL,
GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST,
GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS,
GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL,
GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST,
GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS,
GROUP_REMOVED_ACCOUNTS_CREATE_FAIL,
GROUP_REMOVE_STATUS_REQUEST,
GROUP_REMOVE_STATUS_SUCCESS,
GROUP_REMOVE_STATUS_FAIL,
fetchGroup,
fetchGroupRequest,
fetchGroupSuccess,
fetchGroupFail,
fetchGroupRelationships,
fetchGroupRelationshipsRequest,
fetchGroupRelationshipsSuccess,
fetchGroupRelationshipsFail,
fetchGroups,
fetchGroupsRequest,
fetchGroupsSuccess,
fetchGroupsFail,
joinGroup,
leaveGroup,
joinGroupRequest,
joinGroupSuccess,
joinGroupFail,
leaveGroupRequest,
leaveGroupSuccess,
leaveGroupFail,
fetchMembers,
fetchMembersRequest,
fetchMembersSuccess,
fetchMembersFail,
expandMembers,
expandMembersRequest,
expandMembersSuccess,
expandMembersFail,
fetchRemovedAccounts,
fetchRemovedAccountsRequest,
fetchRemovedAccountsSuccess,
fetchRemovedAccountsFail,
expandRemovedAccounts,
expandRemovedAccountsRequest,
expandRemovedAccountsSuccess,
expandRemovedAccountsFail,
removeRemovedAccount,
removeRemovedAccountRequest,
removeRemovedAccountSuccess,
removeRemovedAccountFail,
createRemovedAccount,
createRemovedAccountRequest,
createRemovedAccountSuccess,
createRemovedAccountFail,
groupRemoveStatus,
groupRemoveStatusRequest,
groupRemoveStatusSuccess,
groupRemoveStatusFail,
};

@ -66,7 +66,5 @@ export const loadInstance = createAsyncThunk<void, void, { state: RootState }>(
export const fetchNodeinfo = createAsyncThunk<void, void, { state: RootState }>( export const fetchNodeinfo = createAsyncThunk<void, void, { state: RootState }>(
'nodeinfo/fetch', 'nodeinfo/fetch',
async(_arg, { getState }) => { async(_arg, { getState }) => await api(getState).get('/nodeinfo/2.1.json'),
return await api(getState).get('/nodeinfo/2.1.json');
},
); );

@ -21,6 +21,7 @@ const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
const fetchMutes = () => const fetchMutes = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
@ -103,6 +104,14 @@ const toggleHideNotifications = () =>
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
}; };
const changeMuteDuration = (duration: number) =>
(dispatch: AppDispatch) => {
dispatch({
type: MUTES_CHANGE_DURATION,
duration,
});
};
export { export {
MUTES_FETCH_REQUEST, MUTES_FETCH_REQUEST,
MUTES_FETCH_SUCCESS, MUTES_FETCH_SUCCESS,
@ -112,6 +121,7 @@ export {
MUTES_EXPAND_FAIL, MUTES_EXPAND_FAIL,
MUTES_INIT_MODAL, MUTES_INIT_MODAL,
MUTES_TOGGLE_HIDE_NOTIFICATIONS, MUTES_TOGGLE_HIDE_NOTIFICATIONS,
MUTES_CHANGE_DURATION,
fetchMutes, fetchMutes,
fetchMutesRequest, fetchMutesRequest,
fetchMutesSuccess, fetchMutesSuccess,
@ -122,4 +132,5 @@ export {
expandMutesFail, expandMutesFail,
initMuteModal, initMuteModal,
toggleHideNotifications, toggleHideNotifications,
changeMuteDuration,
}; };

@ -11,7 +11,7 @@ import { getFilters, regexFromFilters } from 'soapbox/selectors';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features'; import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features';
import { unescapeHTML } from 'soapbox/utils/html'; import { unescapeHTML } from 'soapbox/utils/html';
import { NOTIFICATION_TYPES } from 'soapbox/utils/notification'; import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from 'soapbox/utils/notification';
import { joinPublicPath } from 'soapbox/utils/static'; import { joinPublicPath } from 'soapbox/utils/static';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
@ -195,7 +195,9 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => an
if (activeFilter === 'all') { if (activeFilter === 'all') {
if (features.notificationsIncludeTypes) { if (features.notificationsIncludeTypes) {
params.types = NOTIFICATION_TYPES; params.types = NOTIFICATION_TYPES.filter(type => !EXCLUDE_TYPES.includes(type as any));
} else {
params.exclude_types = EXCLUDE_TYPES;
} }
} else { } else {
if (features.notificationsIncludeTypes) { if (features.notificationsIncludeTypes) {

@ -43,6 +43,11 @@ const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
const STATUS_REVEAL = 'STATUS_REVEAL'; const STATUS_REVEAL = 'STATUS_REVEAL';
const STATUS_HIDE = 'STATUS_HIDE'; const STATUS_HIDE = 'STATUS_HIDE';
const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST';
const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const statusExists = (getState: () => RootState, statusId: string) => { const statusExists = (getState: () => RootState, statusId: string) => {
return (getState().statuses.get(statusId) || null) !== null; return (getState().statuses.get(statusId) || null) !== null;
}; };
@ -305,6 +310,31 @@ const toggleStatusHidden = (status: Status) => {
} }
}; };
const translateStatus = (id: string, targetLanguage?: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: STATUS_TRANSLATE_REQUEST, id });
api(getState).post(`/api/v1/statuses/${id}/translate`, {
target_language: targetLanguage,
}).then(response => {
dispatch({
type: STATUS_TRANSLATE_SUCCESS,
id,
translation: response.data,
});
}).catch(error => {
dispatch({
type: STATUS_TRANSLATE_FAIL,
id,
error,
});
});
};
const undoStatusTranslation = (id: string) => ({
type: STATUS_TRANSLATE_UNDO,
id,
});
export { export {
STATUS_CREATE_REQUEST, STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS, STATUS_CREATE_SUCCESS,
@ -329,6 +359,10 @@ export {
STATUS_UNMUTE_FAIL, STATUS_UNMUTE_FAIL,
STATUS_REVEAL, STATUS_REVEAL,
STATUS_HIDE, STATUS_HIDE,
STATUS_TRANSLATE_REQUEST,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO,
createStatus, createStatus,
editStatus, editStatus,
fetchStatus, fetchStatus,
@ -345,4 +379,6 @@ export {
hideStatus, hideStatus,
revealStatus, revealStatus,
toggleStatusHidden, toggleStatusHidden,
translateStatus,
undoStatusTranslation,
}; };

@ -235,6 +235,14 @@ const Account = ({
<Icon className='h-5 w-5 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/pencil.svg')} /> <Icon className='h-5 w-5 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/pencil.svg')} />
</> </>
) : null} ) : null}
{actionType === 'muting' && account.mute_expires_at ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
<Text theme='muted' size='sm'><RelativeTimestamp timestamp={account.mute_expires_at} futureDate /></Text>
</>
) : null}
</HStack> </HStack>
{withAccountNote && ( {withAccountNote && (

@ -1,89 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StillImage from 'soapbox/components/still_image';
export default class AvatarComposite extends React.PureComponent {
static propTypes = {
accounts: ImmutablePropTypes.list.isRequired,
size: PropTypes.number.isRequired,
};
renderItem(account, size, index) {
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && index > 0)) {
height = 50;
}
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
const style = {
left: left,
top: top,
right: right,
bottom: bottom,
width: `${width}%`,
height: `${height}%`,
};
return (
<StillImage key={account.get('id')} src={account.get('avatar')} style={style} />
);
}
render() {
const { accounts, size } = this.props;
return (
<div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}>
{accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))}
</div>
);
}
}

@ -14,7 +14,6 @@ import SubNavigation from 'soapbox/components/sub_navigation';
// hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, // hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
// }); // });
export default @withRouter
class ColumnHeader extends React.PureComponent { class ColumnHeader extends React.PureComponent {
static propTypes = { static propTypes = {
@ -126,3 +125,5 @@ class ColumnHeader extends React.PureComponent {
// } // }
} }
export default withRouter(ColumnHeader);

@ -8,6 +8,7 @@ import { Text, Stack } from 'soapbox/components/ui';
import { captureException } from 'soapbox/monitoring'; import { captureException } from 'soapbox/monitoring';
import KVStore from 'soapbox/storage/kv_store'; import KVStore from 'soapbox/storage/kv_store';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
import { unregisterSw } from 'soapbox/utils/sw';
import SiteLogo from './site-logo'; import SiteLogo from './site-logo';
@ -15,16 +16,6 @@ import type { RootState } from 'soapbox/store';
const goHome = () => location.href = '/'; const goHome = () => location.href = '/';
/** Unregister the ServiceWorker */
// https://stackoverflow.com/a/49771828/8811886
const unregisterSw = async(): Promise<void> => {
if (navigator.serviceWorker) {
const registrations = await navigator.serviceWorker.getRegistrations();
const unregisterAll = registrations.map(r => r.unregister());
await Promise.all(unregisterAll);
}
};
const mapStateToProps = (state: RootState) => { const mapStateToProps = (state: RootState) => {
const { links, logo } = getSoapboxConfig(state); const { links, logo } = getSoapboxConfig(state);

@ -1,155 +0,0 @@
import classNames from 'clsx';
import debounce from 'lodash/debounce';
import PropTypes from 'prop-types';
import React from 'react';
import { withRouter } from 'react-router-dom';
export default @withRouter
class FilterBar extends React.PureComponent {
static propTypes = {
items: PropTypes.array.isRequired,
active: PropTypes.string,
className: PropTypes.string,
history: PropTypes.object,
};
state = {
mounted: false,
};
componentDidMount() {
this.node.addEventListener('keydown', this.handleKeyDown, false);
window.addEventListener('resize', this.handleResize, { passive: true });
const { left, width } = this.getActiveTabIndicationSize();
this.setState({ mounted: true, left, width });
}
componentWillUnmount() {
this.node.removeEventListener('keydown', this.handleKeyDown, false);
document.removeEventListener('resize', this.handleResize, false);
}
handleResize = debounce(() => {
this.setState(this.getActiveTabIndicationSize());
}, 300, {
trailing: true,
});
componentDidUpdate(prevProps) {
if (this.props.active !== prevProps.active) {
this.setState(this.getActiveTabIndicationSize());
}
}
setRef = c => {
this.node = c;
}
setFocusRef = c => {
this.focusedItem = c;
}
handleKeyDown = e => {
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement);
let element = null;
switch (e.key) {
case 'ArrowRight':
element = items[index + 1] || items[0];
break;
case 'ArrowLeft':
element = items[index - 1] || items[items.length - 1];
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
}
handleItemKeyPress = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
}
handleClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i];
if (typeof action === 'function') {
e.preventDefault();
action(e);
} else if (to) {
e.preventDefault();
this.props.history.push(to);
}
}
getActiveTabIndicationSize() {
const { active, items } = this.props;
if (!active || !this.node) return { width: null };
const index = items.findIndex(({ name }) => name === active);
const elements = Array.from(this.node.getElementsByTagName('a'));
const element = elements[index];
if (!element) return { width: null };
const left = element.offsetLeft;
const { width } = element.getBoundingClientRect();
return { left, width };
}
renderActiveTabIndicator() {
const { left, width } = this.state;
return (
<div className='filter-bar__active' style={{ left, width }} />
);
}
renderItem(option, i) {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { name, text, href, to, title } = option;
return (
<a
key={name}
href={href || to || '#'}
role='button'
tabIndex='0'
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onKeyPress={this.handleItemKeyPress}
data-index={i}
title={title}
>
{text}
</a>
);
}
render() {
const { className, items } = this.props;
const { mounted } = this.state;
return (
<div className={classNames('filter-bar', className)} ref={this.setRef}>
{mounted && this.renderActiveTabIndicator()}
{items.map((option, i) => this.renderItem(option, i))}
</div>
);
}
}

@ -116,7 +116,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
collapsable collapsable
/> />
{(status.media_attachments.size > 0) && ( {(status.card || status.media_attachments.size > 0) && (
<StatusMedia <StatusMedia
status={status} status={status}
muted={compose} muted={compose}

@ -1,34 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
export default class SettingText extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
settingKey: PropTypes.array.isRequired,
label: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
handleChange = (e) => {
this.props.onChange(this.props.settingKey, e.target.value);
}
render() {
const { settings, settingKey, label } = this.props;
return (
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={settings.getIn(settingKey)}
onChange={this.handleChange}
placeholder={label}
/>
</label>
);
}
}

@ -9,6 +9,7 @@ import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden } from 'soapbox/actions/statuses'; import { toggleStatusHidden } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import TranslateButton from 'soapbox/components/translate-button';
import AccountContainer from 'soapbox/containers/account_container'; import AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
import { useAppDispatch, useSettings } from 'soapbox/hooks'; import { useAppDispatch, useSettings } from 'soapbox/hooks';
@ -370,9 +371,12 @@ const Status: React.FC<IStatus> = (props) => {
status={actualStatus} status={actualStatus}
onClick={handleClick} onClick={handleClick}
collapsable collapsable
translatable
/> />
{(quote || actualStatus.media_attachments.size > 0) && ( <TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}> <Stack space={4}>
<StatusMedia <StatusMedia
status={actualStatus} status={actualStatus}

@ -39,10 +39,11 @@ interface IStatusContent {
status: Status, status: Status,
onClick?: () => void, onClick?: () => void,
collapsable?: boolean, collapsable?: boolean,
translatable?: boolean,
} }
/** Renders the text content of a status */ /** Renders the text content of a status */
const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable = false }) => { const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable = false, translatable }) => {
const history = useHistory(); const history = useHistory();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@ -154,14 +155,14 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
}; };
const parsedHtml = useMemo((): string => { const parsedHtml = useMemo((): string => {
const { contentHtml: html } = status; const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
if (greentext) { if (greentext) {
return addGreentext(html); return addGreentext(html);
} else { } else {
return html; return html;
} }
}, [status.contentHtml]); }, [status.contentHtml, status.translation]);
if (status.content.length === 0) { if (status.content.length === 0) {
return null; return null;

@ -4,34 +4,22 @@ import { defineMessages, useIntl } from 'react-intl';
// import { connect } from 'react-redux'; // import { connect } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
// import { openModal } from 'soapbox/actions/modals';
// import { useAppDispatch } from 'soapbox/hooks';
import { CardHeader, CardTitle } from './ui'; import { CardHeader, CardTitle } from './ui';
const messages = defineMessages({ const messages = defineMessages({
back: { id: 'column_back_button.label', defaultMessage: 'Back' }, back: { id: 'column_back_button.label', defaultMessage: 'Back' },
settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
}); });
interface ISubNavigation { interface ISubNavigation {
message: String, message: React.ReactNode,
/** @deprecated Unused. */
settings?: React.ComponentType, settings?: React.ComponentType,
} }
const SubNavigation: React.FC<ISubNavigation> = ({ message }) => { const SubNavigation: React.FC<ISubNavigation> = ({ message }) => {
const intl = useIntl(); const intl = useIntl();
// const dispatch = useAppDispatch();
const history = useHistory(); const history = useHistory();
// const ref = useRef(null);
// const [scrolled, setScrolled] = useState(false);
// const onOpenSettings = () => {
// dispatch(openModal('COMPONENT', { component: Settings }));
// };
const handleBackClick = () => { const handleBackClick = () => {
if (window.history && window.history.length === 1) { if (window.history && window.history.length === 1) {
history.push('/'); history.push('/');
@ -40,36 +28,6 @@ const SubNavigation: React.FC<ISubNavigation> = ({ message }) => {
} }
}; };
// const handleBackKeyUp = (e) => {
// if (e.key === 'Enter') {
// handleClick();
// }
// }
// const handleOpenSettings = () => {
// onOpenSettings();
// }
// useEffect(() => {
// const handleScroll = throttle(() => {
// if (this.node) {
// const { offsetTop } = this.node;
// if (offsetTop > 0) {
// setScrolled(true);
// } else {
// setScrolled(false);
// }
// }
// }, 150, { trailing: true });
// window.addEventListener('scroll', handleScroll);
// return () => {
// window.removeEventListener('scroll', handleScroll);
// };
// }, []);
return ( return (
<CardHeader <CardHeader
aria-label={intl.formatMessage(messages.back)} aria-label={intl.formatMessage(messages.back)}

@ -0,0 +1,59 @@
import React from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { translateStatus, undoStatusTranslation } from 'soapbox/actions/statuses';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { Stack } from './ui';
import type { Status } from 'soapbox/types/entities';
interface ITranslateButton {
status: Status,
}
const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const me = useAppSelector((state) => state.me);
const renderTranslate = /* translationEnabled && */ me && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language;
const handleTranslate: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
if (status.translation) {
dispatch(undoStatusTranslation(status.id));
} else {
dispatch(translateStatus(status.id, intl.locale));
}
};
if (!features.translations || !renderTranslate) return null;
if (status.translation) {
const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' });
const languageName = languageNames.of(status.language!);
const provider = status.translation.get('provider');
return (
<Stack className='text-gray-700 dark:text-gray-600 text-sm' alignItems='start'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue hover:underline' onClick={handleTranslate}>
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
</button>
</Stack>
);
}
return (
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue text-left text-sm hover:underline' onClick={handleTranslate}>
<FormattedMessage id='status.translate' defaultMessage='Translate' />
</button>
);
};
export default TranslateButton;

@ -40,6 +40,8 @@ interface IHStack {
space?: keyof typeof spaces space?: keyof typeof spaces
/** Whether to let the flexbox grow. */ /** Whether to let the flexbox grow. */
grow?: boolean grow?: boolean
/** HTML element to use for container. */
element?: keyof JSX.IntrinsicElements,
/** Extra CSS styles for the <div> */ /** Extra CSS styles for the <div> */
style?: React.CSSProperties style?: React.CSSProperties
/** Whether to let the flexbox wrap onto multiple lines. */ /** Whether to let the flexbox wrap onto multiple lines. */
@ -48,10 +50,12 @@ interface IHStack {
/** Horizontal row of child elements. */ /** Horizontal row of child elements. */
const HStack = forwardRef<HTMLDivElement, IHStack>((props, ref) => { const HStack = forwardRef<HTMLDivElement, IHStack>((props, ref) => {
const { space, alignItems, grow, justifyContent, wrap, className, ...filteredProps } = props; const { space, alignItems, justifyContent, className, grow, element = 'div', wrap, ...filteredProps } = props;
const Elem = element as 'div';
return ( return (
<div <Elem
{...filteredProps} {...filteredProps}
ref={ref} ref={ref}
className={classNames('flex', { className={classNames('flex', {

@ -20,29 +20,35 @@ const justifyContentOptions = {
}; };
const alignItemsOptions = { const alignItemsOptions = {
top: 'items-start',
bottom: 'items-end',
center: 'items-center', center: 'items-center',
start: 'items-start', start: 'items-start',
}; };
interface IStack extends React.HTMLAttributes<HTMLDivElement> { interface IStack extends React.HTMLAttributes<HTMLDivElement> {
/** Size of the gap between elements. */
space?: keyof typeof spaces
/** Horizontal alignment of children. */ /** Horizontal alignment of children. */
alignItems?: 'center' | 'start', alignItems?: keyof typeof alignItemsOptions
/** Extra class names on the element. */
className?: string
/** Vertical alignment of children. */ /** Vertical alignment of children. */
justifyContent?: keyof typeof justifyContentOptions justifyContent?: keyof typeof justifyContentOptions
/** Extra class names on the <div> element. */ /** Size of the gap between elements. */
className?: string space?: keyof typeof spaces
/** Whether to let the flexbox grow. */ /** Whether to let the flexbox grow. */
grow?: boolean grow?: boolean
/** HTML element to use for container. */
element?: keyof JSX.IntrinsicElements,
} }
/** Vertical stack of child elements. */ /** Vertical stack of child elements. */
const Stack = React.forwardRef<HTMLDivElement, IStack>((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => { const Stack = React.forwardRef<HTMLDivElement, IStack>((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => {
const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props; const { space, alignItems, justifyContent, className, grow, element = 'div', ...filteredProps } = props;
const Elem = element as 'div';
return ( return (
<div <Elem
{...filteredProps} {...filteredProps}
ref={ref} ref={ref}
className={classNames('flex flex-col', { className={classNames('flex flex-col', {

@ -18,8 +18,6 @@ const messages = defineMessages({
searchPlaceholder: { id: 'admin.user_index.search_input_placeholder', defaultMessage: 'Who are you looking for?' }, searchPlaceholder: { id: 'admin.user_index.search_input_placeholder', defaultMessage: 'Who are you looking for?' },
}); });
export default @connect()
@injectIntl
class UserIndex extends ImmutablePureComponent { class UserIndex extends ImmutablePureComponent {
static propTypes = { static propTypes = {
@ -130,3 +128,5 @@ class UserIndex extends ImmutablePureComponent {
} }
} }
export default injectIntl(connect()(UserIndex));

@ -1,26 +1,45 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { defineMessages, FormattedDate, useIntl } from 'react-intl'; import { defineMessages, FormattedDate, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security'; import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security';
import { Button, Card, CardBody, CardHeader, CardTitle, Column, Spinner, Stack, Text } from 'soapbox/components/ui'; import { Button, Card, CardBody, CardHeader, CardTitle, Column, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Token } from 'soapbox/reducers/security'; import { Token } from 'soapbox/reducers/security';
import type { Map as ImmutableMap } from 'immutable';
const messages = defineMessages({ const messages = defineMessages({
header: { id: 'security.headers.tokens', defaultMessage: 'Sessions' }, header: { id: 'security.headers.tokens', defaultMessage: 'Sessions' },
revoke: { id: 'security.tokens.revoke', defaultMessage: 'Revoke' }, revoke: { id: 'security.tokens.revoke', defaultMessage: 'Revoke' },
revokeSessionHeading: { id: 'confirmations.revoke_session.heading', defaultMessage: 'Revoke current session' },
revokeSessionMessage: { id: 'confirmations.revoke_session.message', defaultMessage: 'You are about to revoke your current session. You will be signed out.' },
revokeSessionConfirm: { id: 'confirmations.revoke_session.confirm', defaultMessage: 'Revoke' },
}); });
interface IAuthToken { interface IAuthToken {
token: Token, token: Token,
isCurrent: boolean,
} }
const AuthToken: React.FC<IAuthToken> = ({ token }) => { const AuthToken: React.FC<IAuthToken> = ({ token, isCurrent }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const handleRevoke = () => { const handleRevoke = () => {
if (isCurrent)
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/alert-triangle.svg'),
heading: intl.formatMessage(messages.revokeSessionHeading),
message: intl.formatMessage(messages.revokeSessionMessage),
confirm: intl.formatMessage(messages.revokeSessionConfirm),
onConfirm: () => {
dispatch(revokeOAuthTokenById(token.id));
},
}));
else {
dispatch(revokeOAuthTokenById(token.id)); dispatch(revokeOAuthTokenById(token.id));
}
}; };
return ( return (
@ -42,7 +61,7 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
</Stack> </Stack>
<div className='flex justify-end'> <div className='flex justify-end'>
<Button theme='primary' onClick={handleRevoke}> <Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}>
{intl.formatMessage(messages.revoke)} {intl.formatMessage(messages.revoke)}
</Button> </Button>
</div> </div>
@ -55,6 +74,11 @@ const AuthTokenList: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const tokens = useAppSelector(state => state.security.get('tokens').reverse()); const tokens = useAppSelector(state => state.security.get('tokens').reverse());
const currentTokenId = useAppSelector(state => {
const currentToken = state.auth.get('tokens').valueSeq().find((token: ImmutableMap<string, any>) => token.get('me') === state.auth.get('me'));
return currentToken?.get('id');
});
useEffect(() => { useEffect(() => {
dispatch(fetchOAuthTokens()); dispatch(fetchOAuthTokens());
@ -63,7 +87,7 @@ const AuthTokenList: React.FC = () => {
const body = tokens ? ( const body = tokens ? (
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> <div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
{tokens.map((token) => ( {tokens.map((token) => (
<AuthToken key={token.id} token={token} /> <AuthToken key={token.id} token={token} isCurrent={token.id === currentTokenId} />
))} ))}
</div> </div>
) : <Spinner />; ) : <Spinner />;

@ -1,51 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import IconButton from 'soapbox/components/icon_button';
import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
render() {
const { intl, settings, onChange, onClose } = this.props;
return (
<div className='column-settings'>
<div className='column-settings__header'>
<h1 className='column-settings__title'>
<FormattedMessage id='community.column_settings.title' defaultMessage='Local timeline settings' />
</h1>
<div className='column-settings__close'>
<IconButton title={intl.formatMessage(messages.close)} src={require('@tabler/icons/x.svg')} onClick={onClose} />
</div>
</div>
<div className='column-settings__content'>
<div className='column-settings__row'>
<SettingToggle prefix='community_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
</div>
</div>
</div>
);
}
}

@ -1,18 +0,0 @@
import { connect } from 'react-redux';
import { getSettings, changeSetting } from '../../../actions/settings';
import ColumnSettings from '../components/column_settings';
const mapStateToProps = state => ({
settings: getSettings(state).get('community'),
});
const mapDispatchToProps = (dispatch) => {
return {
onChange(key, checked) {
dispatch(changeSetting(['community', ...key], checked));
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

@ -10,8 +10,6 @@ import { useAppDispatch, useSettings } from 'soapbox/hooks';
import Timeline from '../ui/components/timeline'; import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' }, title: { id: 'column.community', defaultMessage: 'Local timeline' },
}); });
@ -44,7 +42,10 @@ const CommunityTimeline = () => {
return ( return (
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}> <Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} /> <div className='px-4 sm:p-0'>
<SubNavigation message={intl.formatMessage(messages.title)} />
</div>
<PullToRefresh onRefresh={handleRefresh}> <PullToRefresh onRefresh={handleRefresh}>
<Timeline <Timeline
scrollKey={`${timelineId}_timeline`} scrollKey={`${timelineId}_timeline`}

@ -132,7 +132,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
setComposeFocused(true); setComposeFocused(true);
}; };
const handleSubmit = () => { const handleSubmit = (e?: React.FormEvent<Element>) => {
if (text !== autosuggestTextareaRef.current?.textarea?.value) { if (text !== autosuggestTextareaRef.current?.textarea?.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly) // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text // Update the state to match the current text
@ -142,6 +142,10 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
// Submit disabled: // Submit disabled:
const fulltext = [spoilerText, countableText(text)].join(''); const fulltext = [spoilerText, countableText(text)].join('');
if (e) {
e.preventDefault();
}
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return; return;
} }
@ -261,7 +265,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
} }
return ( return (
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick}> <Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
{scheduledStatusCount > 0 && ( {scheduledStatusCount > 0 && (
<Warning <Warning
message={( message={(
@ -339,7 +343,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
</div> </div>
)} )}
<Button theme='primary' text={publishText} onClick={handleSubmit} disabled={disabledButton} /> <Button type='submit' theme='primary' text={publishText} disabled={disabledButton} />
</div> </div>
</div> </div>
</Stack> </Stack>

@ -168,7 +168,7 @@ const PollForm: React.FC<IPollForm> = ({ composeId }) => {
<Divider /> <Divider />
<button onClick={handleToggleMultiple} className='text-left'> <button type='button' onClick={handleToggleMultiple} className='text-left'>
<HStack alignItems='center' justifyContent='between'> <HStack alignItems='center' justifyContent='between'>
<Stack> <Stack>
<Text weight='medium'> <Text weight='medium'>
@ -197,7 +197,7 @@ const PollForm: React.FC<IPollForm> = ({ composeId }) => {
{/* Remove Poll */} {/* Remove Poll */}
<div className='text-center'> <div className='text-center'>
<button className='text-danger-500' onClick={onRemovePoll}> <button type='button' className='text-danger-500' onClick={onRemovePoll}>
{intl.formatMessage(messages.removePoll)} {intl.formatMessage(messages.removePoll)}
</button> </button>
</div> </div>

@ -68,7 +68,7 @@ const SpoilerInput = React.forwardRef<AutosuggestInput, ISpoilerInput>(({
/> />
<div className='text-center'> <div className='text-center'>
<button className='text-danger-500' onClick={handleRemove}> <button type='button' className='text-danger-500' onClick={handleRemove}>
{intl.formatMessage(messages.remove)} {intl.formatMessage(messages.remove)}
</button> </button>
</div> </div>

@ -0,0 +1,24 @@
import classNames from 'clsx';
import React from 'react';
interface IIndicator {
state?: 'active' | 'pending' | 'error' | 'inactive',
size?: 'sm',
}
/** Indicator dot component. */
const Indicator: React.FC<IIndicator> = ({ state = 'inactive', size = 'sm' }) => {
return (
<div
className={classNames('rounded-full outline-double', {
'w-1.5 h-1.5 shadow-sm': size === 'sm',
'bg-green-500 outline-green-400': state === 'active',
'bg-yellow-500 outline-yellow-400': state === 'pending',
'bg-red-500 outline-red-400': state === 'error',
'bg-neutral-500 outline-neutral-400': state === 'inactive',
})}
/>
);
};
export default Indicator;

@ -89,6 +89,14 @@ const Developers: React.FC = () => {
</Text> </Text>
</DashWidget> </DashWidget>
<DashWidget to='/developers/sw'>
<SvgIcon src={require('@tabler/icons/script.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.service_worker_label' defaultMessage='Service Worker' />
</Text>
</DashWidget>
<DashWidget onClick={leaveDevelopers}> <DashWidget onClick={leaveDevelopers}>
<SvgIcon src={require('@tabler/icons/logout.svg')} className='text-gray-700 dark:text-gray-600' /> <SvgIcon src={require('@tabler/icons/logout.svg')} className='text-gray-700 dark:text-gray-600' />

@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import List, { ListItem } from 'soapbox/components/list';
import { HStack, Text, Column, FormActions, Button, Stack, Icon } from 'soapbox/components/ui';
import { unregisterSw } from 'soapbox/utils/sw';
import Indicator from './components/indicator';
const messages = defineMessages({
heading: { id: 'column.developers.service_worker', defaultMessage: 'Service Worker' },
status: { id: 'sw.status', defaultMessage: 'Status' },
url: { id: 'sw.url', defaultMessage: 'Script URL' },
});
/** Hook that returns the active ServiceWorker registration. */
const useRegistration = () => {
const [isLoading, setLoading] = useState(true);
const [registration, setRegistration] = useState<ServiceWorkerRegistration>();
const isSupported = 'serviceWorker' in navigator;
useEffect(() => {
if (isSupported) {
navigator.serviceWorker.getRegistration()
.then(r => {
setRegistration(r);
setLoading(false);
})
.catch(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
return {
isLoading,
registration,
};
};
interface IServiceWorkerInfo {
}
/** Mini ServiceWorker debugging component. */
const ServiceWorkerInfo: React.FC<IServiceWorkerInfo> = () => {
const intl = useIntl();
const { isLoading, registration } = useRegistration();
const url = registration?.active?.scriptURL;
const getState = () => {
if (registration?.waiting) {
return 'pending';
} else if (registration?.active) {
return 'active';
} else {
return 'inactive';
}
};
const getMessage = () => {
if (isLoading) {
return (
<FormattedMessage
id='sw.state.loading'
defaultMessage='Loading…'
/>
);
} else if (!isLoading && !registration) {
return (
<FormattedMessage
id='sw.state.unavailable'
defaultMessage='Unavailable'
/>
);
} else if (registration?.waiting) {
return (
<FormattedMessage
id='sw.state.waiting'
defaultMessage='Waiting'
/>
);
} else if (registration?.active) {
return (
<FormattedMessage
id='sw.state.active'
defaultMessage='Active'
/>
);
} else {
return (
<FormattedMessage
id='sw.state.unknown'
defaultMessage='Unknown'
/>
);
}
};
const handleRestart = async() => {
await unregisterSw();
window.location.reload();
};
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Stack space={4}>
<List>
<ListItem label={intl.formatMessage(messages.status)}>
<HStack alignItems='center' space={2}>
<Indicator state={getState()} />
<Text size='md' theme='muted'>{getMessage()}</Text>
</HStack>
</ListItem>
{url && (
<ListItem label={intl.formatMessage(messages.url)}>
<a href={url} target='_blank' className='flex space-x-1 items-center truncate'>
<span className='truncate'>{url}</span>
<Icon
className='w-4 h-4'
src={require('@tabler/icons/external-link.svg')}
/>
</a>
</ListItem>
)}
</List>
<FormActions>
<Button theme='tertiary' type='button' onClick={handleRestart}>
<FormattedMessage id='sw.restart' defaultMessage='Restart' />
</Button>
</FormActions>
</Stack>
</Column>
);
};
export default ServiceWorkerInfo;

@ -1,39 +1,29 @@
'use strict'; 'use strict';
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { Text } from 'soapbox/components/ui'; import { Text } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
const hasRestrictions = remoteInstance => { import type { Map as ImmutableMap } from 'immutable';
const hasRestrictions = (remoteInstance: ImmutableMap<string, any>): boolean => {
return remoteInstance return remoteInstance
.get('federation') .get('federation')
.deleteAll(['accept', 'reject_deletes', 'report_removal']) .deleteAll(['accept', 'reject_deletes', 'report_removal'])
.reduce((acc, value) => acc || value, false); .reduce((acc: boolean, value: boolean) => acc || value, false);
};
const mapStateToProps = state => {
return {
instance: state.get('instance'),
};
}; };
export default @connect(mapStateToProps) interface IInstanceRestrictions {
class InstanceRestrictions extends ImmutablePureComponent { remoteInstance: ImmutableMap<string, any>,
}
static propTypes = { const InstanceRestrictions: React.FC<IInstanceRestrictions> = ({ remoteInstance }) => {
intl: PropTypes.object.isRequired, const instance = useAppSelector(state => state.instance);
remoteInstance: ImmutablePropTypes.map.isRequired,
instance: ImmutablePropTypes.map,
};
renderRestrictions = () => { const renderRestrictions = () => {
const { remoteInstance } = this.props;
const items = []; const items = [];
const { const {
@ -105,10 +95,9 @@ class InstanceRestrictions extends ImmutablePureComponent {
} }
return items; return items;
} };
renderContent = () => { const renderContent = () => {
const { instance, remoteInstance } = this.props;
if (!instance || !remoteInstance) return null; if (!instance || !remoteInstance) return null;
const host = remoteInstance.get('host'); const host = remoteInstance.get('host');
@ -136,7 +125,7 @@ class InstanceRestrictions extends ImmutablePureComponent {
/> />
</Text> </Text>
), ),
this.renderRestrictions(), renderRestrictions(),
]; ];
} else { } else {
return ( return (
@ -150,14 +139,13 @@ class InstanceRestrictions extends ImmutablePureComponent {
</Text> </Text>
); );
} }
} };
render() {
return ( return (
<div className='py-1 pl-4 mb-4 border-solid border-l-[3px] border-gray-300 dark:border-gray-500'> <div className='py-1 pl-4 mb-4 border-solid border-l-[3px] border-gray-300 dark:border-gray-500'>
{this.renderContent()} {renderContent()}
</div> </div>
); );
} };
} export default InstanceRestrictions;

@ -3,10 +3,10 @@ import { FormattedMessage } from 'react-intl';
import { connectHashtagStream } from 'soapbox/actions/streaming'; import { connectHashtagStream } from 'soapbox/actions/streaming';
import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
import ColumnHeader from 'soapbox/components/column_header'; import SubNavigation from 'soapbox/components/sub_navigation';
import { Column } from 'soapbox/components/ui'; import { Column } from 'soapbox/components/ui';
import Timeline from 'soapbox/features/ui/components/timeline'; import Timeline from 'soapbox/features/ui/components/timeline';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import type { Tag as TagEntity } from 'soapbox/types/entities'; import type { Tag as TagEntity } from 'soapbox/types/entities';
@ -27,7 +27,6 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
const tags = params?.tags || { any: [], all: [], none: [] }; const tags = params?.tags || { any: [], all: [], none: [] };
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const hasUnread = useAppSelector<boolean>(state => (state.timelines.getIn([`hashtag:${id}`, 'unread']) as number) > 0);
const disconnects = useRef<(() => void)[]>([]); const disconnects = useRef<(() => void)[]>([]);
// Mastodon supports displaying results from multiple hashtags. // Mastodon supports displaying results from multiple hashtags.
@ -100,7 +99,10 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
return ( return (
<Column label={`#${id}`} transparent withHeader={false}> <Column label={`#${id}`} transparent withHeader={false}>
<ColumnHeader active={hasUnread} title={title()} /> <div className='px-4 pt-4 sm:p-0'>
<SubNavigation message={title()} />
</div>
<Timeline <Timeline
scrollKey='hashtag_timeline' scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`} timelineId={`hashtag:${id}`}

@ -1,55 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import IconButton from 'soapbox/components/icon_button';
import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
render() {
const { intl, settings, onChange, onClose } = this.props;
return (
<div className='column-settings'>
<div className='column-settings__header'>
<h1 className='column-settings__title'>
<FormattedMessage id='home.column_settings.title' defaultMessage='Home settings' />
</h1>
<div className='column-settings__close'>
<IconButton title={intl.formatMessage(messages.close)} src={require('@tabler/icons/x.svg')} onClick={onClose} />
</div>
</div>
<div className='column-settings__content'>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reposts' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'direct']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_direct' defaultMessage='Show direct messages' />} />
</div>
</div>
</div>
);
}
}

@ -1,26 +0,0 @@
import { connect } from 'react-redux';
import {
getSettings,
changeSetting,
saveSettings,
} from '../../../actions/settings';
import ColumnSettings from '../components/column_settings';
const mapStateToProps = state => ({
settings: getSettings(state).get('home'),
});
const mapDispatchToProps = dispatch => ({
onChange(key, checked) {
dispatch(changeSetting(['home', ...key], checked));
},
onSave() {
dispatch(saveSettings());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

@ -1,192 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import IconButton from 'soapbox/components/icon_button';
import ClearColumnButton from './clear_column_button';
import MultiSettingToggle from './multi_setting_toggle';
import SettingToggle from './setting_toggle';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
settings: ImmutablePropTypes.map.isRequired,
pushSettings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
supportsEmojiReacts: PropTypes.bool,
supportsBirthdays: PropTypes.bool,
};
onPushChange = (path, checked) => {
this.props.onChange(['push', ...path], checked);
}
onAllSoundsChange = (path, checked) => {
const soundSettings = [['sounds', 'follow'], ['sounds', 'favourite'], ['sounds', 'pleroma:emoji_reaction'], ['sounds', 'mention'], ['sounds', 'reblog'], ['sounds', 'poll'], ['sounds', 'move']];
for (let i = 0; i < soundSettings.length; i++) {
this.props.onChange(soundSettings[i], checked);
}
}
render() {
const { intl, settings, pushSettings, onChange, onClear, onClose, supportsEmojiReacts, supportsBirthdays } = this.props;
const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const allSoundsStr = <FormattedMessage id='notifications.column_settings.sounds.all_sounds' defaultMessage='Play sound for all notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
const soundSettings = [['sounds', 'follow'], ['sounds', 'favourite'], ['sounds', 'pleroma:emoji_reaction'], ['sounds', 'mention'], ['sounds', 'reblog'], ['sounds', 'poll'], ['sounds', 'move']];
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
const birthdaysStr = <FormattedMessage id='notifications.column_settings.birthdays.show' defaultMessage='Show birthday reminders' />;
return (
<div className='column-settings'>
<div className='column-settings__header'>
<h1 className='column-settings__title'>
<FormattedMessage id='notifications.column_settings.title' defaultMessage='Notification settings' />
</h1>
<div className='column-settings__close'>
<IconButton title={intl.formatMessage(messages.close)} src={require('@tabler/icons/x.svg')} onClick={onClose} />
</div>
</div>
<div className='column-settings__content'>
<div className='column-settings__row'>
<ClearColumnButton onClick={onClear} />
</div>
<div role='group' aria-labelledby='notifications-all_sounds'>
<span id='notifications-filter-bar' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.sounds' defaultMessage='Sounds' />
</span>
<MultiSettingToggle prefix='notifications_all_sounds' settings={settings} settingPaths={soundSettings} onChange={this.onAllSoundsChange} label={allSoundsStr} />
</div>
<div role='group' aria-labelledby='notifications-filter-bar'>
<span id='notifications-filter-bar' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
</span>
<div className='column-settings__row'>
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
</div>
</div>
{supportsBirthdays &&
<div role='group' aria-labelledby='notifications-filter-bar'>
<span id='notifications-filter-bar' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.birthdays.category' defaultMessage='Birthdays' />
</span>
<div className='column-settings__row'>
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['birthdays', 'show']} onChange={onChange} label={birthdaysStr} />
</div>
</div>
}
<div role='group' aria-labelledby='notifications-follow'>
<span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-follow-request'>
<span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-favourite'>
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Likes:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div>
</div>
{supportsEmojiReacts && <div role='group' aria-labelledby='notifications-emoji-react'>
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.emoji_react' defaultMessage='Emoji reacts:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'pleroma:emoji_reaction']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'pleroma:emoji_reaction']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'pleroma:emoji_reaction']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'pleroma:emoji_reaction']} onChange={onChange} label={soundStr} />
</div>
</div>}
<div role='group' aria-labelledby='notifications-mention'>
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-reblog'>
<span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Reposts:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-poll'>
<span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-move'>
<span id='notifications-move' className='column-settings__section'><FormattedMessage id='notifications.column_settings.move' defaultMessage='Moves:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'move']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'move']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'move']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'move']} onChange={onChange} label={soundStr} />
</div>
</div>
</div>
</div>
);
}
}

@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import React, { Fragment } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display-name';
import IconButton from 'soapbox/components/icon_button';
import Permalink from 'soapbox/components/permalink';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
});
export default @injectIntl
class FollowRequest extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
onAuthorize: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render() {
const { intl, hidden, account, onAuthorize, onReject } = this.props;
if (!account) {
return <div />;
}
if (hidden) {
return (
<Fragment>
{account.get('display_name')}
{account.get('username')}
</Fragment>
);
}
return (
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Permalink>
<div className='account__relationship'>
<IconButton title={intl.formatMessage(messages.authorize)} src={require('@tabler/icons/check.svg')} onClick={onAuthorize} />
<IconButton title={intl.formatMessage(messages.reject)} src={require('@tabler/icons/x.svg')} onClick={onReject} />
</div>
</div>
</div>
);
}
}

@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Toggle from 'react-toggle';
export default class MultiSettingToggle extends React.PureComponent {
static propTypes = {
prefix: PropTypes.string,
settings: ImmutablePropTypes.map.isRequired,
settingPaths: PropTypes.array.isRequired,
label: PropTypes.node,
onChange: PropTypes.func.isRequired,
ariaLabel: PropTypes.string,
}
onChange = ({ target }) => {
for (let i = 0; i < this.props.settingPaths.length; i++) {
this.props.onChange(this.props.settingPaths[i], target.checked);
}
}
areTrue = (settingPath) => {
return this.props.settings.getIn(settingPath) === true;
}
render() {
const { prefix, settingPaths, label, ariaLabel } = this.props;
const id = ['setting-toggle', prefix].filter(Boolean).join('-');
return (
<div className='setting-toggle' aria-label={ariaLabel}>
<Toggle id={id} checked={settingPaths.every(this.areTrue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
{label && (<label htmlFor={id} className='setting-toggle__label'>{label}</label>)}
</div>
);
}
}

@ -1,55 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'soapbox/actions/modals';
import { clearNotifications, setFilter } from 'soapbox/actions/notifications';
import { changeAlerts as changePushNotifications } from 'soapbox/actions/push_notifications';
import { getSettings, changeSetting } from 'soapbox/actions/settings';
import { getFeatures } from 'soapbox/utils/features';
import ColumnSettings from '../components/column_settings';
const messages = defineMessages({
clearHeading: { id: 'notifications.clear_heading', defaultMessage: 'Clear notifications' },
clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
});
const mapStateToProps = state => {
const instance = state.get('instance');
const features = getFeatures(instance);
return {
settings: getSettings(state).get('notifications'),
pushSettings: state.get('push_notifications'),
supportsEmojiReacts: features.emojiReacts,
supportsBirthdays: features.birthdays,
};
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onChange(path, checked) {
if (path[0] === 'push') {
dispatch(changePushNotifications(path.slice(1), checked));
} else if (path[0] === 'quickFilter') {
dispatch(changeSetting(['notifications', ...path], checked));
dispatch(setFilter('all'));
} else {
dispatch(changeSetting(['notifications', ...path], checked));
}
},
onClear() {
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/eraser.svg'),
heading: intl.formatMessage(messages.clearHeading),
message: intl.formatMessage(messages.clearMessage),
confirm: intl.formatMessage(messages.clearConfirm),
onConfirm: () => dispatch(clearNotifications()),
}));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));

@ -1,28 +0,0 @@
import { connect } from 'react-redux';
import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts';
import { makeGetAccount } from 'soapbox/selectors';
import FollowRequest from '../components/follow_request';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { id }) => ({
onAuthorize() {
dispatch(authorizeFollowRequest(id));
},
onReject() {
dispatch(rejectFollowRequest(id));
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(FollowRequest);

@ -1,55 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import IconButton from 'soapbox/components/icon_button';
import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
render() {
const { intl, settings, onChange, onClose } = this.props;
return (
<div className='column-settings'>
<div className='column-settings__header'>
<h1 className='column-settings__title'>
<FormattedMessage id='public.column_settings.title' defaultMessage='Fediverse timeline settings' />
</h1>
<div className='column-settings__close'>
<IconButton title={intl.formatMessage(messages.close)} src={require('@tabler/icons/x.svg')} onClick={onClose} />
</div>
</div>
<div className='column-settings__content'>
<div className='column-settings__row'>
<SettingToggle prefix='public_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reposts' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='public_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
</div>
</div>
</div>
);
}
}

@ -1,18 +0,0 @@
import { connect } from 'react-redux';
import { getSettings, changeSetting } from '../../../actions/settings';
import ColumnSettings from '../components/column_settings';
const mapStateToProps = state => ({
settings: getSettings(state).get('public'),
});
const mapDispatchToProps = (dispatch) => {
return {
onChange(key, checked) {
dispatch(changeSetting(['public', ...key], checked));
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

@ -14,8 +14,6 @@ import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker'; import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker';
import Timeline from '../ui/components/timeline'; import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Fediverse timeline' }, title: { id: 'column.public', defaultMessage: 'Fediverse timeline' },
dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' }, dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' },
@ -65,8 +63,12 @@ const CommunityTimeline = () => {
return ( return (
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}> <Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} /> <div className='px-4 sm:p-0'>
<SubNavigation message={intl.formatMessage(messages.title)} />
</div>
<PinnedHostsPicker /> <PinnedHostsPicker />
{showExplanationBox && <div className='mb-4'> {showExplanationBox && <div className='mb-4'>
<Accordion <Accordion
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />} headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}

@ -0,0 +1,154 @@
import classNames from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import Picker from 'emoji-mart/dist-es/components/picker/picker';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
emoji: { id: 'icon_button.label', defaultMessage: 'Select icon' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'icon_button.not_found', defaultMessage: 'No icons!! (╯°□°)╯︵ ┻━┻' },
custom: { id: 'icon_button.icons', defaultMessage: 'Icons' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
});
const backgroundImageFn = () => '';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = ['custom'];
class IconPickerMenu extends React.PureComponent {
static propTypes = {
custom_emojis: PropTypes.object,
loading: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired,
style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired,
};
static defaultProps = {
style: {},
loading: true,
};
state = {
modifierOpen: false,
placement: null,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
if (!c) return;
// Nice and dirty hack to display the icons
c.querySelectorAll('button.emoji-mart-emoji > img').forEach(elem => {
const newIcon = document.createElement('span');
newIcon.innerHTML = `<i class="fa fa-${elem.parentNode.getAttribute('title')} fa-hack"></i>`;
elem.parentNode.replaceChild(newIcon, elem);
});
}
getI18n = () => {
const { intl } = this.props;
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
custom: intl.formatMessage(messages.custom),
},
};
}
handleClick = emoji => {
emoji.native = emoji.colons;
this.props.onClose();
this.props.onPick(emoji);
}
buildIcons = (customEmojis, autoplay = false) => {
const emojis = [];
Object.values(customEmojis).forEach(category => {
category.forEach(function(icon) {
const name = icon.replace('fa fa-', '');
if (icon !== 'email' && icon !== 'memo') {
emojis.push({
id: name,
name,
short_names: [name],
emoticons: [],
keywords: [name],
imageUrl: '',
});
}
});
});
return emojis;
};
render() {
const { loading, style, intl, custom_emojis } = this.props;
if (loading) {
return <div style={{ width: 299 }} />;
}
const data = { compressed: true, categories: [], aliases: [], emojis: [] };
const title = intl.formatMessage(messages.emoji);
const { modifierOpen } = this.state;
return (
<div className={classNames('font-icon-picker emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<Picker
perLine={8}
emojiSize={22}
include={categoriesSort}
sheetSize={32}
custom={this.buildIcons(custom_emojis)}
color=''
emoji=''
set=''
title={title}
i18n={this.getI18n()}
onClick={this.handleClick}
showPreview={false}
backgroundImageFn={backgroundImageFn}
emojiTooltip
noShowAnchors
data={data}
/>
</div>
);
}
}
export default injectIntl(IconPickerMenu);

@ -1,6 +1,3 @@
import classNames from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import Picker from 'emoji-mart/dist-es/components/picker/picker';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
@ -8,153 +5,12 @@ import Overlay from 'react-overlays/lib/Overlay';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import IconPickerMenu from './icon-picker-menu';
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'icon_button.label', defaultMessage: 'Select icon' }, emoji: { id: 'icon_button.label', defaultMessage: 'Select icon' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'icon_button.not_found', defaultMessage: 'No icons!! (╯°□°)╯︵ ┻━┻' },
custom: { id: 'icon_button.icons', defaultMessage: 'Icons' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
});
const backgroundImageFn = () => '';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = ['custom'];
@injectIntl
class IconPickerMenu extends React.PureComponent {
static propTypes = {
custom_emojis: PropTypes.object,
loading: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired,
style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired,
};
static defaultProps = {
style: {},
loading: true,
};
state = {
modifierOpen: false,
placement: null,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
if (!c) return;
// Nice and dirty hack to display the icons
c.querySelectorAll('button.emoji-mart-emoji > img').forEach(elem => {
const newIcon = document.createElement('span');
newIcon.innerHTML = `<i class="fa fa-${elem.parentNode.getAttribute('title')} fa-hack"></i>`;
elem.parentNode.replaceChild(newIcon, elem);
}); });
}
getI18n = () => {
const { intl } = this.props;
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
custom: intl.formatMessage(messages.custom),
},
};
}
handleClick = emoji => {
emoji.native = emoji.colons;
this.props.onClose();
this.props.onPick(emoji);
}
buildIcons = (customEmojis, autoplay = false) => {
const emojis = [];
Object.values(customEmojis).forEach(category => {
category.forEach(function(icon) {
const name = icon.replace('fa fa-', '');
if (icon !== 'email' && icon !== 'memo') {
emojis.push({
id: name,
name,
short_names: [name],
emoticons: [],
keywords: [name],
imageUrl: '',
});
}
});
});
return emojis;
};
render() {
const { loading, style, intl, custom_emojis } = this.props;
if (loading) {
return <div style={{ width: 299 }} />;
}
const data = { compressed: true, categories: [], aliases: [], emojis: [] };
const title = intl.formatMessage(messages.emoji);
const { modifierOpen } = this.state;
return (
<div className={classNames('font-icon-picker emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<Picker
perLine={8}
emojiSize={22}
include={categoriesSort}
sheetSize={32}
custom={this.buildIcons(custom_emojis)}
color=''
emoji=''
set=''
title={title}
i18n={this.getI18n()}
onClick={this.handleClick}
showPreview={false}
backgroundImageFn={backgroundImageFn}
emojiTooltip
noShowAnchors
data={data}
/>
</div>
);
}
}
export default @injectIntl
class IconPickerDropdown extends React.PureComponent { class IconPickerDropdown extends React.PureComponent {
static propTypes = { static propTypes = {
@ -243,3 +99,5 @@ class IconPickerDropdown extends React.PureComponent {
} }
} }
export default injectIntl(IconPickerDropdown);

@ -7,6 +7,7 @@ import StatusMedia from 'soapbox/components/status-media';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import StatusContent from 'soapbox/components/status_content'; import StatusContent from 'soapbox/components/status_content';
import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay'; import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay';
import TranslateButton from 'soapbox/components/translate-button';
import { HStack, Stack, Text } from 'soapbox/components/ui'; import { HStack, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container'; import AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
@ -101,9 +102,11 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
)} )}
<Stack space={4}> <Stack space={4}>
<StatusContent status={actualStatus} /> <StatusContent status={actualStatus} translatable />
{(quote || actualStatus.media_attachments.size > 0) && ( <TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}> <Stack space={4}>
<StatusMedia <StatusMedia
status={actualStatus} status={actualStatus}

@ -1,57 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Link } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
import AccountContainer from 'soapbox/containers/account_container';
export default class AccountListPanel extends ImmutablePureComponent {
static propTypes = {
title: PropTypes.node.isRequired,
accountIds: ImmutablePropTypes.orderedSet.isRequired,
icon: PropTypes.string.isRequired,
limit: PropTypes.number,
total: PropTypes.number,
expandMessage: PropTypes.string,
expandRoute: PropTypes.string,
};
static defaultProps = {
limit: Infinity,
}
render() {
const { title, icon, accountIds, limit, total, expandMessage, expandRoute, ...props } = this.props;
if (!accountIds || accountIds.isEmpty()) {
return null;
}
const canExpand = expandMessage && expandRoute && (accountIds.size < total);
return (
<div className='wtf-panel'>
<div className='wtf-panel-header'>
<Icon src={icon} className='wtf-panel-header__icon' />
<span className='wtf-panel-header__label'>
{title}
</span>
</div>
<div className='wtf-panel__content'>
<div className='wtf-panel__list'>
{accountIds.take(limit).map(accountId => (
<AccountContainer key={accountId} id={accountId} {...props} />
))}
</div>
</div>
{canExpand && <Link className='wtf-panel__expand-btn' to={expandRoute}>
{expandMessage}
</Link>}
</div>
);
}
}

@ -4,9 +4,10 @@ import Toggle from 'react-toggle';
import { muteAccount } from 'soapbox/actions/accounts'; import { muteAccount } from 'soapbox/actions/accounts';
import { closeModal } from 'soapbox/actions/modals'; import { closeModal } from 'soapbox/actions/modals';
import { toggleHideNotifications } from 'soapbox/actions/mutes'; import { toggleHideNotifications, changeMuteDuration } from 'soapbox/actions/mutes';
import { Modal, HStack, Stack, Text } from 'soapbox/components/ui'; import { Modal, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import DurationSelector from 'soapbox/features/compose/components/polls/duration-selector';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
const getAccount = makeGetAccount(); const getAccount = makeGetAccount();
@ -16,12 +17,14 @@ const MuteModal = () => {
const account = useAppSelector((state) => getAccount(state, state.mutes.new.accountId!)); const account = useAppSelector((state) => getAccount(state, state.mutes.new.accountId!));
const notifications = useAppSelector((state) => state.mutes.new.notifications); const notifications = useAppSelector((state) => state.mutes.new.notifications);
const duration = useAppSelector((state) => state.mutes.new.duration);
const mutesDuration = useFeatures().mutesDuration;
if (!account) return null; if (!account) return null;
const handleClick = () => { const handleClick = () => {
dispatch(closeModal()); dispatch(closeModal());
dispatch(muteAccount(account.id, notifications)); dispatch(muteAccount(account.id, notifications, duration));
}; };
const handleCancel = () => { const handleCancel = () => {
@ -32,6 +35,12 @@ const MuteModal = () => {
dispatch(toggleHideNotifications()); dispatch(toggleHideNotifications());
}; };
const handleChangeMuteDuration = (expiresIn: number): void => {
dispatch(changeMuteDuration(expiresIn));
};
const toggleAutoExpire = () => handleChangeMuteDuration(duration ? 0 : 2 * 60 * 60 * 24);
return ( return (
<Modal <Modal
title={ title={
@ -69,6 +78,32 @@ const MuteModal = () => {
/> />
</HStack> </HStack>
</label> </label>
{mutesDuration && (
<>
<label>
<HStack alignItems='center' space={2}>
<Text tag='span'>
<FormattedMessage id='mute_modal.auto_expire' defaultMessage='Automatically expire mute?' />
</Text>
<Toggle
checked={duration !== 0}
onChange={toggleAutoExpire}
icons={false}
/>
</HStack>
</label>
{duration !== 0 && (
<Stack space={2}>
<Text weight='medium'><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </Text>
<DurationSelector onDurationChange={handleChangeMuteDuration} />
</Stack>
)}
</>
)}
</Stack> </Stack>
</Modal> </Modal>
); );

@ -39,6 +39,8 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
const features = useFeatures(); const features = useFeatures();
const intl = useIntl(); const intl = useIntl();
useAppSelector((state) => console.log(state.auth.toJS()));
const authUsers = useAppSelector((state) => state.auth.get('users')); const authUsers = useAppSelector((state) => state.auth.get('users'));
const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.get('id')))); const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.get('id'))));

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { useIntl, MessageDescriptor } from 'react-intl'; import { useIntl, MessageDescriptor } from 'react-intl';
import { NotificationStack, NotificationObject, StyleFactoryFn } from 'react-notification';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { dismissAlert } from 'soapbox/actions/alerts'; import { dismissAlert } from 'soapbox/actions/alerts';
import { Button } from 'soapbox/components/ui'; import { Button } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { NotificationStack, NotificationObject, StyleFactoryFn } from 'soapbox/react-notification';
import type { Alert } from 'soapbox/reducers/alerts'; import type { Alert } from 'soapbox/reducers/alerts';

@ -106,6 +106,7 @@ import {
TestTimeline, TestTimeline,
LogoutPage, LogoutPage,
AuthTokenList, AuthTokenList,
ServiceWorkerInfo,
} from './util/async-components'; } from './util/async-components';
import { WrappedRoute } from './util/react_router_helpers'; import { WrappedRoute } from './util/react_router_helpers';
@ -295,6 +296,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<WrappedRoute path='/developers/apps/create' developerOnly page={DefaultPage} component={CreateApp} content={children} /> <WrappedRoute path='/developers/apps/create' developerOnly page={DefaultPage} component={CreateApp} content={children} />
<WrappedRoute path='/developers/settings_store' developerOnly page={DefaultPage} component={SettingsStore} content={children} /> <WrappedRoute path='/developers/settings_store' developerOnly page={DefaultPage} component={SettingsStore} content={children} />
<WrappedRoute path='/developers/timeline' developerOnly page={DefaultPage} component={TestTimeline} content={children} /> <WrappedRoute path='/developers/timeline' developerOnly page={DefaultPage} component={TestTimeline} content={children} />
<WrappedRoute path='/developers/sw' developerOnly page={DefaultPage} component={ServiceWorkerInfo} content={children} />
<WrappedRoute path='/developers' page={DefaultPage} component={Developers} content={children} /> <WrappedRoute path='/developers' page={DefaultPage} component={Developers} content={children} />
<WrappedRoute path='/error/network' developerOnly page={EmptyPage} component={() => new Promise((_resolve, reject) => reject())} content={children} /> <WrappedRoute path='/error/network' developerOnly page={EmptyPage} component={() => new Promise((_resolve, reject) => reject())} content={children} />
<WrappedRoute path='/error' developerOnly page={EmptyPage} component={IntentionalError} content={children} /> <WrappedRoute path='/error' developerOnly page={EmptyPage} component={IntentionalError} content={children} />

@ -470,6 +470,10 @@ export function TestTimeline() {
return import(/* webpackChunkName: "features/test_timeline" */'../../test_timeline'); return import(/* webpackChunkName: "features/test_timeline" */'../../test_timeline');
} }
export function ServiceWorkerInfo() {
return import(/* webpackChunkName: "features/developers" */'../../developers/service-worker-info');
}
export function DatePicker() { export function DatePicker() {
return import(/* webpackChunkName: "date_picker" */'../../birthdays/date_picker'); return import(/* webpackChunkName: "date_picker" */'../../birthdays/date_picker');
} }

File diff suppressed because it is too large Load Diff

@ -1211,8 +1211,11 @@
"status.show_less_all": "Zwiń wszystkie", "status.show_less_all": "Zwiń wszystkie",
"status.show_more": "Rozwiń", "status.show_more": "Rozwiń",
"status.show_more_all": "Rozwiń wszystkie", "status.show_more_all": "Rozwiń wszystkie",
"status.show_original": "Pokaż oryginalny wpis",
"status.title": "Wpis", "status.title": "Wpis",
"status.title_direct": "Wiadomość bezpośrednia", "status.title_direct": "Wiadomość bezpośrednia",
"status.translated_from_with": "Przetłumaczono z {lang} z użyciem {provider}",
"status.translate": "Przetłumacz wpis",
"status.unbookmark": "Usuń z zakładek", "status.unbookmark": "Usuń z zakładek",
"status.unbookmarked": "Usunięto z zakładek.", "status.unbookmarked": "Usunięto z zakładek.",
"status.unmute_conversation": "Cofnij wyciszenie konwersacji", "status.unmute_conversation": "Cofnij wyciszenie konwersacji",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -44,6 +44,7 @@ export const AccountRecord = ImmutableRecord({
location: '', location: '',
locked: false, locked: false,
moved: null as EmbeddedEntity<any>, moved: null as EmbeddedEntity<any>,
mute_expires_at: null as string | null,
note: '', note: '',
pleroma: ImmutableMap<string, any>(), pleroma: ImmutableMap<string, any>(),
source: ImmutableMap<string, any>(), source: ImmutableMap<string, any>(),

@ -63,6 +63,7 @@ export const StatusRecord = ImmutableRecord({
hidden: false, hidden: false,
search_index: '', search_index: '',
spoilerHtml: '', spoilerHtml: '',
translation: null as ImmutableMap<string, string> | null,
}); });
const normalizeAttachments = (status: ImmutableMap<string, any>) => { const normalizeAttachments = (status: ImmutableMap<string, any>) => {

@ -3,10 +3,10 @@
* @module soapbox/precheck * @module soapbox/precheck
*/ */
/** Whether pre-rendered data exists in Mastodon's format. */ /** Whether pre-rendered data exists in Pleroma's format. */
const hasPrerenderPleroma = Boolean(document.getElementById('initial-results')); const hasPrerenderPleroma = Boolean(document.getElementById('initial-results'));
/** Whether pre-rendered data exists in Pleroma's format. */ /** Whether pre-rendered data exists in Mastodon's format. */
const hasPrerenderMastodon = Boolean(document.getElementById('initial-state')); const hasPrerenderMastodon = Boolean(document.getElementById('initial-state'));
/** Whether initial data was loaded into the page by server-side-rendering (SSR). */ /** Whether initial data was loaded into the page by server-side-rendering (SSR). */

@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
export default {
message: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element,
]).isRequired,
action: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.string,
PropTypes.node,
]),
onClick: PropTypes.func,
style: PropTypes.bool,
actionStyle: PropTypes.object,
titleStyle: PropTypes.object,
barStyle: PropTypes.object,
activeBarStyle: PropTypes.object,
dismissAfter: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.number,
]),
onDismiss: PropTypes.func,
className: PropTypes.string,
activeClassName: PropTypes.string,
isActive: PropTypes.bool,
title: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node,
]),
};

@ -1,88 +0,0 @@
declare module 'soapbox/react-notification' {
import { Component, ReactElement } from 'react';
interface StyleFactoryFn {
(index: number, style: object | void, notification: NotificationProps): object;
}
interface OnClickNotificationProps {
/**
* Callback function to run when the action is clicked.
* @param notification Notification currently being clicked
* @param deactivate Function that can be called to set the notification to inactive.
* Used to activate notification exit animation on click.
*/
onClick?(notification: NotificationProps, deactivate: () => void): void;
}
interface NotificationProps extends OnClickNotificationProps {
/** The name of the action, e.g., "close" or "undo". */
action?: string;
/** Custom action styles. */
actionStyle?: object;
/** Custom snackbar styles when the bar is active. */
activeBarStyle?: object;
/**
* Custom class to apply to the top-level component when active.
* @default 'notification-bar-active'
*/
activeClassName?: string;
/** Custom snackbar styles. */
barStyle?: object;
/** Custom class to apply to the top-level component. */
className?: string;
/**
* Timeout for onDismiss event.
* @default 2000
*/
dismissAfter?: boolean | number;
/**
* If true, the notification is visible.
* @default false
*/
isActive?: boolean;
/** The message or component for the notification. */
message: string | ReactElement<NotificationProps>;
/** Setting this prop to `false` will disable all inline styles. */
style?: boolean;
/** The title for the notification. */
title?: string | ReactElement<any>;
/** Custom title styles. */
titleStyle?: object;
/**
* Callback function to run when dismissAfter timer runs out
* @param notification Notification currently being dismissed.
*/
onDismiss?(notification: NotificationProps): void;
}
interface NotificationStackProps extends OnClickNotificationProps {
/** Create the style of the actions. */
actionStyleFactory?: StyleFactoryFn;
/** Create the style of the active notification. */
activeBarStyleFactory?: StyleFactoryFn;
/** Create the style of the notification. */
barStyleFactory?: StyleFactoryFn;
/**
* If false, notification dismiss timers start immediately.
* @default true
*/
dismissInOrder?: boolean;
/** Array of notifications to render. */
notifications: NotificationObject[];
/**
* Callback function to run when dismissAfter timer runs out
* @param notification Notification currently being dismissed.
*/
onDismiss?(notification: NotificationObject): void;
}
export interface NotificationObject extends NotificationProps {
key: number | string;
}
export class Notification extends Component<NotificationProps, {}> {}
export class NotificationStack extends Component<NotificationStackProps, {}> {}
}

@ -1,2 +0,0 @@
export { default as Notification } from './notification';
export { default as NotificationStack } from './notificationStack';

@ -1,175 +0,0 @@
/* linting temp disabled while working on updates */
/* eslint-disable */
import React, { Component } from 'react';
import defaultPropTypes from './defaultPropTypes';
class Notification extends Component {
constructor(props) {
super(props);
this.getBarStyle = this.getBarStyle.bind(this);
this.getActionStyle = this.getActionStyle.bind(this);
this.getTitleStyle = this.getTitleStyle.bind(this);
this.handleClick = this.handleClick.bind(this);
if (props.onDismiss && props.isActive) {
this.dismissTimeout = setTimeout(
props.onDismiss,
props.dismissAfter
);
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.dismissAfter === false) return;
// See http://eslint.org/docs/rules/no-prototype-builtins
if (!{}.hasOwnProperty.call(nextProps, 'isLast')) {
clearTimeout(this.dismissTimeout);
}
if (nextProps.onDismiss) {
if (
(nextProps.isActive && !this.props.isActive) ||
(nextProps.dismissAfter && this.props.dismissAfter === false)
) {
this.dismissTimeout = setTimeout(
nextProps.onDismiss,
nextProps.dismissAfter
);
}
}
}
componentWillUnmount() {
if (this.props.dismissAfter) clearTimeout(this.dismissTimeout);
}
/*
* @description Dynamically get the styles for the bar.
* @returns {object} result The style.
*/
getBarStyle() {
if (this.props.style === false) return {};
const { isActive, barStyle, activeBarStyle } = this.props;
const baseStyle = {
position: 'fixed',
bottom: '2rem',
left: '-100%',
width: 'auto',
padding: '1rem',
margin: 0,
color: '#fafafa',
font: '1rem normal Roboto, sans-serif',
borderRadius: '5px',
background: '#212121',
borderSizing: 'border-box',
boxShadow: '0 0 1px 1px rgba(10, 10, 11, .125)',
cursor: 'default',
WebKitTransition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)',
MozTransition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)',
msTransition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)',
OTransition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)',
transition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)',
WebkitTransform: 'translatez(0)',
MozTransform: 'translatez(0)',
msTransform: 'translatez(0)',
OTransform: 'translatez(0)',
transform: 'translatez(0)'
};
return isActive ?
Object.assign({}, baseStyle, { left: '1rem' }, barStyle, activeBarStyle) :
Object.assign({}, baseStyle, barStyle);
}
/*
* @function getActionStyle
* @description Dynamically get the styles for the action text.
* @returns {object} result The style.
*/
getActionStyle() {
return this.props.style !== false ? Object.assign({}, {
padding: '0.125rem',
marginLeft: '1rem',
color: '#f44336',
font: '.75rem normal Roboto, sans-serif',
lineHeight: '1rem',
letterSpacing: '.125ex',
textTransform: 'uppercase',
borderRadius: '5px',
cursor: 'pointer'
}, this.props.actionStyle) : {};
}
/*
* @function getTitleStyle
* @description Dynamically get the styles for the title.
* @returns {object} result The style.
*/
getTitleStyle() {
return this.props.style !== false ? Object.assign({}, {
fontWeight: '700',
marginRight: '.5rem'
}, this.props.titleStyle) : {};
}
/*
* @function handleClick
* @description Handle click events on the action button.
*/
handleClick() {
if (this.props.onClick && typeof this.props.onClick === 'function') {
return this.props.onClick();
}
}
render() {
let className = 'notification-bar';
if (this.props.isActive) className += ` ${this.props.activeClassName}`;
if (this.props.className) className += ` ${this.props.className}`;
return (
<div className={className} style={this.getBarStyle()}>
<div className="notification-bar-wrapper">
{this.props.title ? (
<span
className="notification-bar-title"
style={this.getTitleStyle()}
>
{this.props.title}
</span>
) : null}
{/* eslint-disable */}
<span className="notification-bar-message">
{this.props.message}
</span>
{this.props.action ? (
<span
className="notification-bar-action"
onClick={this.handleClick}
style={this.getActionStyle()}
>
{this.props.action}
</span>
) : null}
</div>
</div>
);
}
}
Notification.propTypes = defaultPropTypes;
Notification.defaultProps = {
isActive: false,
dismissAfter: 2000,
activeClassName: 'notification-bar-active'
};
export default Notification;

@ -1,95 +0,0 @@
/* linting temp disabled while working on updates */
/* eslint-disable */
import React from 'react';
import PropTypes from 'prop-types';
import StackedNotification from './stackedNotification';
import defaultPropTypes from './defaultPropTypes';
function defaultBarStyleFactory(index, style) {
return Object.assign(
{},
style,
{ bottom: `${2 + (index * 4)}rem` }
);
}
function defaultActionStyleFactory(index, style) {
return Object.assign(
{},
style,
{}
);
}
/**
* The notification list does not have any state, so use a
* pure function here. It just needs to return the stacked array
* of notification components.
*/
const NotificationStack = props => (
<div className="notification-list">
{props.notifications.map((notification, index) => {
const isLast = index === 0 && props.notifications.length === 1;
const dismissNow = isLast || !props.dismissInOrder;
// Handle styles
const barStyle = props.barStyleFactory(index, notification.barStyle, notification);
const actionStyle = props.actionStyleFactory(index, notification.actionStyle, notification);
const activeBarStyle = props.activeBarStyleFactory(
index,
notification.activeBarStyle,
notification
);
// Allow onClick from notification stack or individual notifications
const onClick = notification.onClick || props.onClick;
const onDismiss = props.onDismiss;
let { dismissAfter } = notification;
if (dismissAfter !== false) {
if (dismissAfter == null) dismissAfter = props.dismissAfter;
if (!dismissNow) dismissAfter += index * 1000;
}
return (
<StackedNotification
{...notification}
key={notification.key}
isLast={isLast}
action={notification.action || props.action}
dismissAfter={dismissAfter}
onDismiss={onDismiss.bind(this, notification)}
onClick={onClick.bind(this, notification)}
activeBarStyle={activeBarStyle}
barStyle={barStyle}
actionStyle={actionStyle}
/>
);
})}
</div>
);
/* eslint-disable react/no-unused-prop-types, react/forbid-prop-types */
NotificationStack.propTypes = {
activeBarStyleFactory: PropTypes.func,
barStyleFactory: PropTypes.func,
actionStyleFactory: PropTypes.func,
dismissInOrder: PropTypes.bool,
notifications: PropTypes.array.isRequired,
onDismiss: PropTypes.func.isRequired,
onClick: PropTypes.func,
action: defaultPropTypes.action
};
NotificationStack.defaultProps = {
activeBarStyleFactory: defaultBarStyleFactory,
barStyleFactory: defaultBarStyleFactory,
actionStyleFactory: defaultActionStyleFactory,
dismissInOrder: true,
dismissAfter: 1000,
onClick: () => {}
};
/* eslint-enable no-alert, no-console */
export default NotificationStack;

@ -1,69 +0,0 @@
/* linting temp disabled while working on updates */
/* eslint-disable */
import React, { Component } from 'react';
import defaultPropTypes from './defaultPropTypes';
import Notification from './notification';
class StackedNotification extends Component {
constructor(props) {
super(props);
this.state = {
isActive: false
};
this.handleClick = this.handleClick.bind(this);
}
componentDidMount() {
this.activeTimeout = setTimeout(this.setState.bind(this, {
isActive: true
}), 1);
this.dismiss(this.props.dismissAfter);
}
componentWillReceiveProps(nextProps) {
if (nextProps.dismissAfter !== this.props.dismissAfter) {
this.dismiss(nextProps.dismissAfter);
}
}
componentWillUnmount() {
clearTimeout(this.activeTimeout);
clearTimeout(this.dismissTimeout);
}
dismiss(dismissAfter) {
if (dismissAfter === false) return;
this.dismissTimeout = setTimeout(this.setState.bind(this, {
isActive: false
}), dismissAfter);
}
/*
* @function handleClick
* @description Bind deactivate Notification function to Notification click handler
*/
handleClick() {
if (this.props.onClick && typeof this.props.onClick === 'function') {
return this.props.onClick(this.setState.bind(this, { isActive: false }));
}
}
render() {
return (
<Notification
{...this.props}
onClick={this.handleClick}
onDismiss={() => setTimeout(this.props.onDismiss, 300)}
isActive={this.state.isActive}
/>
);
}
}
StackedNotification.propTypes = defaultPropTypes;
export default StackedNotification;

@ -1,16 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import reducer from '../group_editor';
describe('group_editor reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {} as any)).toEqual(ImmutableMap({
groupId: null,
isSubmitting: false,
isChanged: false,
title: '',
description: '',
coverImage: null,
}));
});
});

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

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

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

@ -1,153 +0,0 @@
import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import * as actions from 'soapbox/actions/lists';
import reducer from '../list_editor';
describe('list_editor reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toMatchObject({
listId: null,
isSubmitting: false,
isChanged: false,
title: '',
accounts: {
items: ImmutableList(),
loaded: false,
isLoading: false,
},
suggestions: {
value: '',
items: ImmutableList(),
},
});
});
it('should handle LIST_EDITOR_RESET', () => {
const state = ImmutableRecord({
listId: null,
isSubmitting: false,
isChanged: false,
title: '',
accounts: ImmutableRecord({
items: ImmutableList(),
loaded: false,
isLoading: false,
})(),
suggestions: ImmutableRecord({
value: '',
items: ImmutableList(),
})(),
})();
const action = {
type: actions.LIST_EDITOR_RESET,
};
expect(reducer(state, action)).toMatchObject({
listId: null,
isSubmitting: false,
isChanged: false,
title: '',
accounts: {
items: ImmutableList(),
loaded: false,
isLoading: false,
},
suggestions: {
value: '',
items: ImmutableList(),
},
});
});
it('should handle LIST_EDITOR_SETUP', () => {
const state = ImmutableRecord({
listId: null,
isSubmitting: false,
isChanged: false,
title: '',
accounts: ImmutableRecord({
items: ImmutableList(),
loaded: false,
isLoading: false,
})(),
suggestions: ImmutableRecord({
value: '',
items: ImmutableList(),
})(),
})();
const action = {
type: actions.LIST_EDITOR_SETUP,
list: ImmutableMap({
id: '22',
title: 'list 1',
}),
};
expect(reducer(state, action)).toMatchObject({
listId: '22',
isSubmitting: false,
isChanged: false,
title: 'list 1',
accounts: {
items: ImmutableList(),
loaded: false,
isLoading: false,
},
suggestions: {
value: '',
items: ImmutableList(),
},
});
});
it('should handle LIST_EDITOR_TITLE_CHANGE', () => {
const state = ImmutableMap({
title: 'list 1',
isChanged: false,
});
const action = {
type: actions.LIST_EDITOR_TITLE_CHANGE,
value: 'list 1 edited',
};
expect(reducer(state, action).toJS()).toMatchObject({
isChanged: true,
title: 'list 1 edited',
});
});
it('should handle LIST_UPDATE_REQUEST', () => {
const state = ImmutableMap({
isSubmitting: false,
isChanged: true,
});
const action = {
type: actions.LIST_UPDATE_REQUEST,
};
expect(reducer(state, action).toJS()).toMatchObject({
isSubmitting: true,
isChanged: false,
});
});
it('should handle LIST_UPDATE_FAIL', () => {
const state = ImmutableMap({
isSubmitting: true,
});
const action = {
type: actions.LIST_UPDATE_FAIL,
};
expect(reducer(state, action).toJS()).toMatchObject({
isSubmitting: false,
});
});
});

@ -14,6 +14,7 @@ describe('mutes reducer', () => {
isSubmitting: false, isSubmitting: false,
accountId: null, accountId: null,
notifications: true, notifications: true,
duration: 0,
}, },
}); });
}); });
@ -24,6 +25,7 @@ describe('mutes reducer', () => {
isSubmitting: false, isSubmitting: false,
accountId: null, accountId: null,
notifications: true, notifications: true,
duration: 0,
})(), })(),
})(); })();
const action = { const action = {
@ -35,6 +37,7 @@ describe('mutes reducer', () => {
isSubmitting: false, isSubmitting: false,
accountId: 'account1', accountId: 'account1',
notifications: true, notifications: true,
duration: 0,
}, },
}); });
}); });
@ -45,6 +48,7 @@ describe('mutes reducer', () => {
isSubmitting: false, isSubmitting: false,
accountId: null, accountId: null,
notifications: true, notifications: true,
duration: 0,
})(), })(),
})(); })();
const action = { const action = {
@ -55,6 +59,7 @@ describe('mutes reducer', () => {
isSubmitting: false, isSubmitting: false,
accountId: null, accountId: null,
notifications: false, notifications: false,
duration: 0,
}, },
}); });
}); });

@ -1,673 +0,0 @@
import {
Map as ImmutableMap,
OrderedMap as ImmutableOrderedMap,
Record as ImmutableRecord,
} from 'immutable';
import take from 'lodash/take';
import intlMessages from 'soapbox/__fixtures__/intlMessages.json';
import notification from 'soapbox/__fixtures__/notification.json';
import notifications from 'soapbox/__fixtures__/notifications.json';
import relationship from 'soapbox/__fixtures__/relationship.json';
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'soapbox/actions/accounts';
import {
MARKER_FETCH_SUCCESS,
MARKER_SAVE_REQUEST,
MARKER_SAVE_SUCCESS,
} from 'soapbox/actions/markers';
import {
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_EXPAND_FAIL,
NOTIFICATIONS_FILTER_SET,
NOTIFICATIONS_SCROLL_TOP,
NOTIFICATIONS_UPDATE,
NOTIFICATIONS_UPDATE_QUEUE,
NOTIFICATIONS_DEQUEUE,
NOTIFICATIONS_CLEAR,
NOTIFICATIONS_MARK_READ_REQUEST,
} from 'soapbox/actions/notifications';
import { TIMELINE_DELETE } from 'soapbox/actions/timelines';
import { applyActions } from 'soapbox/jest/test-helpers';
import reducer from '../notifications';
const initialState = reducer(undefined, {});
describe('notifications reducer', () => {
it('should return the initial state', () => {
const expected = {
items: {},
hasMore: true,
top: false,
unread: 0,
isLoading: false,
queuedNotifications: {},
totalQueuedNotificationsCount: 0,
lastRead: -1,
};
expect(ImmutableRecord.isRecord(initialState)).toBe(true);
expect(initialState.toJS()).toMatchObject(expected);
});
describe('NOTIFICATIONS_EXPAND_SUCCESS', () => {
it('imports the notifications', () => {
const action = {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications: take(notifications, 3),
next: null,
skipLoading: true,
};
const result = reducer(undefined, action);
// The items are parsed as records
expect(ImmutableOrderedMap.isOrderedMap(result.items)).toBe(true);
expect(ImmutableRecord.isRecord(result.items.get('10743'))).toBe(true);
// We can get an item
expect(result.items.get('10744').emoji).toEqual('😢');
// hasMore is set to false because `next` is null
expect(result.hasMore).toBe(false);
});
it('drops invalid notifications', () => {
const action = {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications: [
{ id: '1', type: 'mention', status: null, account: { id: '10' } },
{ id: '2', type: 'reblog', status: null, account: { id: '9' } },
{ id: '3', type: 'favourite', status: null, account: { id: '8' } },
{ id: '4', type: 'mention', status: { id: 'a' }, account: { id: '7' } },
{ id: '5', type: 'reblog', status: { id: 'b' }, account: null },
],
next: null,
skipLoading: true,
};
const result = reducer(undefined, action);
// Only '4' is valid
expect(result.items.size).toEqual(1);
expect(result.items.get('4').id).toEqual('4');
});
it('imports move notification', () => {
const action = {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications: [
require('soapbox/__fixtures__/pleroma-notification-move.json'),
],
next: null,
skipLoading: true,
};
const result = reducer(undefined, action).items.get('406814');
expect(result.account).toEqual('AFmHQ18XZ7Lco68MW8');
expect(result.target).toEqual('A5c5LK7EJTFR0u26Pg');
});
});
describe('NOTIFICATIONS_EXPAND_REQUEST', () => {
it('sets isLoading to true', () => {
const state = initialState.set('isLoading', false);
const action = { type: NOTIFICATIONS_EXPAND_REQUEST };
expect(reducer(state, action).isLoading).toBe(true);
});
});
describe('NOTIFICATIONS_EXPAND_FAIL', () => {
it('sets isLoading to false', () => {
const state = initialState.set('isLoading', true);
const action = { type: NOTIFICATIONS_EXPAND_FAIL };
expect(reducer(state, action).isLoading).toBe(false);
});
});
describe('NOTIFICATIONS_FILTER_SET', () => {
it('clears the items', () => {
const actions = [{
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications: [
{ id: '1', type: 'mention', status: { id: '4' }, account: { id: '7' } },
{ id: '2', type: 'mention', status: { id: '5' }, account: { id: '8' } },
{ id: '3', type: 'mention', status: { id: '6' }, account: { id: '9' } },
],
next: null,
skipLoading: true,
}, {
type: NOTIFICATIONS_FILTER_SET,
}];
// Setup by expanding, then calling `NOTIFICATIONS_FILTER_SET`
const result = applyActions(initialState, actions, reducer);
// Setting the filter wipes notifications
expect(result.items.isEmpty()).toBe(true);
});
it('sets hasMore to true', () => {
const state = initialState.set('hasMore', false);
const action = { type: NOTIFICATIONS_FILTER_SET };
const result = reducer(state, action);
expect(result.hasMore).toBe(true);
});
});
describe('NOTIFICATIONS_SCROLL_TOP', () => {
it('resets `unread` counter to 0 when top is true (ie, scrolled to the top)', () => {
const state = initialState.set('unread', 1);
const action = { type: NOTIFICATIONS_SCROLL_TOP, top: true };
const result = reducer(state, action);
expect(result.unread).toEqual(0);
expect(result.top).toBe(true);
});
it('leaves `unread` alone when top is false (ie, not scrolled to top)', () => {
const state = initialState.set('unread', 3);
const action = { type: NOTIFICATIONS_SCROLL_TOP, top: false };
const result = reducer(state, action);
expect(result.unread).toEqual(3);
expect(result.top).toBe(false);
});
});
describe('NOTIFICATIONS_UPDATE', () => {
it('imports the notification', () => {
const action = { type: NOTIFICATIONS_UPDATE, notification };
const result = reducer(initialState, action);
expect(result.items.get('10743').type).toEqual('favourite');
});
it('imports follow_request notification', () => {
const action = {
type: NOTIFICATIONS_UPDATE,
notification: require('soapbox/__fixtures__/notification-follow_request.json'),
};
const result = reducer(initialState, action);
expect(result.items.get('87967').type).toEqual('follow_request');
});
it('increments `unread` counter when top is false', () => {
const action = { type: NOTIFICATIONS_UPDATE, notification };
const result = reducer(initialState, action);
expect(result.unread).toEqual(1);
});
});
describe('NOTIFICATIONS_UPDATE_QUEUE', () => {
it('adds the notification to the queue (and increases the counter)', () => {
const action = {
type: NOTIFICATIONS_UPDATE_QUEUE,
notification,
intlMessages,
intlLocale: 'en',
};
const result = reducer(initialState, action);
// Doesn't add it as a regular item
expect(result.items.isEmpty()).toBe(true);
// Adds it to the queued items
expect(result.queuedNotifications.size).toEqual(1);
expect(result.totalQueuedNotificationsCount).toEqual(1);
expect(result.queuedNotifications.getIn(['10743', 'notification', 'type'])).toEqual('favourite');
});
});
describe('NOTIFICATIONS_DEQUEUE', () => {
it('resets the queued counter to 0', () => {
const state = initialState.set('totalQueuedNotificationsCount', 1);
const action = { type: NOTIFICATIONS_DEQUEUE };
const result = reducer(state, action);
expect(result.totalQueuedNotificationsCount).toEqual(0);
});
});
describe('NOTIFICATIONS_EXPAND_SUCCESS', () => {
it('with non-empty items and next set true', () => {
const state = ImmutableMap({
items: ImmutableOrderedMap([
['10734', ImmutableMap({
id: '10734',
type: 'pleroma:emoji_reaction',
account: '9vMAje101ngtjlMj7w',
target: null,
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: null,
})],
]),
unread: 1,
hasMore: true,
isLoading: false,
});
const action = {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications: take(notifications, 3),
next: true,
};
const expected = ImmutableMap({
items: ImmutableOrderedMap([
['10744', ImmutableMap({
id: '10744',
type: 'pleroma:emoji_reaction',
account: '9vMAje101ngtjlMj7w',
target: null,
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: null,
total_count: null,
})],
['10743', ImmutableMap({
id: '10743',
type: 'favourite',
account: '9v5c6xSEgAi3Zu1Lv6',
target: null,
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
total_count: null,
})],
['10741', ImmutableMap({
id: '10741',
type: 'favourite',
account: '9v5cKMOPGqPcgfcWp6',
target: null,
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
total_count: null,
})],
['10734', ImmutableMap({
id: '10734',
type: 'pleroma:emoji_reaction',
account: '9vMAje101ngtjlMj7w',
target: null,
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: null,
})],
]),
unread: 1,
hasMore: true,
isLoading: false,
});
expect(reducer(state, action).toJS()).toEqual(expected.toJS());
});
it('with empty items and next set true', () => {
const state = ImmutableMap({
items: ImmutableOrderedMap(),
unread: 1,
hasMore: true,
isLoading: false,
});
const action = {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications: take(notifications, 3),
next: true,
};
const expected = ImmutableMap({
items: ImmutableOrderedMap([
['10744', ImmutableMap({
id: '10744',
type: 'pleroma:emoji_reaction',
account: '9vMAje101ngtjlMj7w',
target: null,
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: null,
total_count: null,
})],
['10743', ImmutableMap({
id: '10743',
type: 'favourite',
account: '9v5c6xSEgAi3Zu1Lv6',
target: null,
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
total_count: null,
})],
['10741', ImmutableMap({
id: '10741',
type: 'favourite',
account: '9v5cKMOPGqPcgfcWp6',
target: null,
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
total_count: null,
})],
]),
unread: 1,
hasMore: true,
isLoading: false,
});
expect(reducer(state, action).toJS()).toEqual(expected.toJS());
});
});
describe('ACCOUNT_BLOCK_SUCCESS', () => {
it('should handle', () => {
const state = ImmutableMap({
items: ImmutableOrderedMap([
['10744', ImmutableMap({
id: '10744',
type: 'pleroma:emoji_reaction',
account: '9vMAje101ngtjlMj7w',
target: null,
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: null,
})],
['10743', ImmutableMap({
id: '10743',
type: 'favourite',
account: '9v5c6xSEgAi3Zu1Lv6',
target: null,
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
})],
['10741', ImmutableMap({
id: '10741',
type: 'favourite',
account: '9v5cKMOPGqPcgfcWp6',
target: null,
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
})],
]),
});
const action = {
type: ACCOUNT_BLOCK_SUCCESS,
relationship,
};
expect(reducer(state, action)).toEqual(ImmutableMap({
items: ImmutableOrderedMap([
['10743', ImmutableMap({
id: '10743',
type: 'favourite',
account: '9v5c6xSEgAi3Zu1Lv6',
target: null,
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
})],
['10741', ImmutableMap({
id: '10741',
type: 'favourite',
account: '9v5cKMOPGqPcgfcWp6',
target: null,
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
})],
]),
}));
});
});
describe('ACCOUNT_MUTE_SUCCESS', () => {
it('should handle', () => {
const state = ImmutableMap({
items: ImmutableOrderedMap([
['10744', ImmutableMap({
id: '10744',
type: 'pleroma:emoji_reaction',
account: '9vMAje101ngtjlMj7w',
target: null,
created_at: '2020-06-10T02:54:39.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: '😢',
chat_message: null,
})],
['10743', ImmutableMap({
id: '10743',
type: 'favourite',
account: '9v5c6xSEgAi3Zu1Lv6',
target: null,
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
})],
['10741', ImmutableMap({
id: '10741',
type: 'favourite',
account: '9v5cKMOPGqPcgfcWp6',
target: null,
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
})],
]),
});
const action = {
type: ACCOUNT_MUTE_SUCCESS,
relationship: relationship,
};
expect(reducer(state, action)).toEqual(ImmutableMap({
items: ImmutableOrderedMap([
['10743', ImmutableMap({
id: '10743',
type: 'favourite',
account: '9v5c6xSEgAi3Zu1Lv6',
target: null,
created_at: '2020-06-10T02:51:05.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
})],
['10741', ImmutableMap({
id: '10741',
type: 'favourite',
account: '9v5cKMOPGqPcgfcWp6',
target: null,
created_at: '2020-06-10T02:05:06.000Z',
status: '9vvNxoo5EFbbnfdXQu',
emoji: null,
chat_message: null,
})],
]),
}));
});
});
describe('NOTIFICATIONS_CLEAR', () => {
it('clears the items', () => {
const state = initialState.set('items', ImmutableOrderedMap([['1', {}], ['2', {}]]));
const action = { type: NOTIFICATIONS_CLEAR };
const result = reducer(state, action);
expect(result.items.isEmpty()).toBe(true);
});
});
describe('NOTIFICATIONS_MARK_READ_REQUEST', () => {
it('sets lastRead to the one in the action', () => {
const action = { type: NOTIFICATIONS_MARK_READ_REQUEST, lastRead: '1234' };
const result = reducer(undefined, action);
expect(result.lastRead).toEqual('1234');
});
});
describe('TIMELINE_DELETE', () => {
it('deletes notifications corresponding to the status ID', () => {
const actions = [{
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications: [
{ id: '1', type: 'mention', status: { id: '4' }, account: { id: '7' } },
{ id: '2', type: 'mention', status: { id: '5' }, account: { id: '8' } },
{ id: '3', type: 'mention', status: { id: '6' }, account: { id: '9' } },
{ id: '4', type: 'mention', status: { id: '5' }, account: { id: '7' } },
],
next: null,
skipLoading: true,
}, {
type: TIMELINE_DELETE,
id: '5',
}];
// Setup by expanding, then calling `NOTIFICATIONS_FILTER_SET`
const result = applyActions(initialState, actions, reducer);
expect(result.items.size).toEqual(2);
expect(result.items.get('5')).toBe(undefined);
});
});
describe('MARKER_FETCH_SUCCESS', () => {
it('sets lastRead', () => {
const action = {
type: MARKER_FETCH_SUCCESS,
timeline: ['notifications'],
marker: {
notifications: {
last_read_id: '1234',
},
},
};
expect(reducer(undefined, action).get('lastRead')).toEqual('1234');
});
it('updates the unread count', () => {
const action = {
type: MARKER_FETCH_SUCCESS,
timeline: ['notifications'],
marker: {
notifications: {
last_read_id: '5678',
},
},
};
const state = ImmutableMap({
items: ImmutableOrderedMap({
'9012': ImmutableMap({ id: '9012' }),
'5678': ImmutableMap({ id: '5678' }),
'1234': ImmutableMap({ id: '1234' }),
}),
unread: 3,
});
expect(reducer(state, action).get('unread')).toEqual(1);
});
});
describe('MARKER_SAVE_REQUEST', () => {
it('sets lastRead', () => {
const action = {
type: MARKER_SAVE_REQUEST,
timeline: ['notifications'],
marker: {
notifications: {
last_read_id: '1234',
},
},
};
expect(reducer(undefined, action).get('lastRead')).toEqual('1234');
});
it('updates the unread count', () => {
const action = {
type: MARKER_SAVE_REQUEST,
timeline: ['notifications'],
marker: {
notifications: {
last_read_id: '5678',
},
},
};
const state = ImmutableMap({
items: ImmutableOrderedMap({
'9012': ImmutableMap({ id: '9012' }),
'5678': ImmutableMap({ id: '5678' }),
'1234': ImmutableMap({ id: '1234' }),
}),
unread: 3,
});
expect(reducer(state, action).get('unread')).toEqual(1);
});
});
describe('MARKER_SAVE_SUCCESS', () => {
it('sets lastRead', () => {
const action = {
type: MARKER_SAVE_SUCCESS,
timeline: ['notifications'],
marker: {
notifications: {
last_read_id: '5678',
},
},
};
expect(reducer(undefined, action).get('lastRead')).toEqual('5678');
});
it('updates the unread count', () => {
const action = {
type: MARKER_SAVE_SUCCESS,
timeline: ['notifications'],
marker: {
notifications: {
last_read_id: '9012',
},
},
};
const state = ImmutableMap({
items: ImmutableOrderedMap({
'9012': ImmutableMap({ id: '9012' }),
'5678': ImmutableMap({ id: '5678' }),
'1234': ImmutableMap({ id: '1234' }),
}),
unread: 3,
});
expect(reducer(state, action).get('unread')).toEqual(0);
});
});
});

@ -1,131 +0,0 @@
import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import {
SEARCH_CHANGE,
SEARCH_CLEAR,
SEARCH_EXPAND_SUCCESS,
} from 'soapbox/actions/search';
import reducer from '../search';
describe('search reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {}).toJS()).toEqual({
value: '',
submitted: false,
submittedValue: '',
hidden: false,
results: {
accounts: [],
statuses: [],
hashtags: [],
accountsHasMore: false,
statusesHasMore: false,
hashtagsHasMore: false,
accountsLoaded: false,
statusesLoaded: false,
hashtagsLoaded: false,
},
filter: 'accounts',
accountId: null,
});
});
describe('SEARCH_CHANGE', () => {
it('sets the value', () => {
const state = ImmutableMap({ value: 'hell' });
const action = { type: SEARCH_CHANGE, value: 'hello' };
expect(reducer(state, action).get('value')).toEqual('hello');
});
});
describe('SEARCH_CLEAR', () => {
it('resets the state', () => {
const state = ImmutableRecord({
value: 'hello world',
submitted: true,
submittedValue: 'hello world',
hidden: false,
results: ImmutableRecord({})(),
filter: 'statuses',
})();
const action = { type: SEARCH_CLEAR };
const expected = {
value: '',
submitted: false,
submittedValue: '',
hidden: false,
results: {
accounts: [],
statuses: [],
hashtags: [],
accountsHasMore: false,
statusesHasMore: false,
hashtagsHasMore: false,
accountsLoaded: false,
statusesLoaded: false,
hashtagsLoaded: false,
},
filter: 'accounts',
accountId: null,
};
expect(reducer(state, action).toJS()).toEqual(expected);
});
});
describe(SEARCH_EXPAND_SUCCESS, () => {
it('imports hashtags as maps', () => {
const state = ImmutableRecord({
value: 'artist',
submitted: true,
submittedValue: 'artist',
hidden: false,
results: ImmutableRecord({
hashtags: ImmutableList(),
hashtagsHasMore: false,
hashtagsLoaded: true,
})(),
filter: 'hashtags',
})();
const action = {
type: SEARCH_EXPAND_SUCCESS,
results: {
accounts: [],
statuses: [],
hashtags: [{
name: 'artist',
url: 'https://gleasonator.com/tags/artist',
history: [],
}],
},
searchTerm: 'artist',
searchType: 'hashtags',
};
const expected = {
value: 'artist',
submitted: true,
submittedValue: 'artist',
hidden: false,
results: {
hashtags: [
{
name: 'artist',
url: 'https://gleasonator.com/tags/artist',
history: [],
},
],
hashtagsHasMore: false,
hashtagsLoaded: true,
},
filter: 'hashtags',
};
expect(reducer(state, action).toJS()).toEqual(expected);
});
});
});

@ -14,8 +14,6 @@ describe('user_lists reducer', () => {
blocks: { next: null, items: ImmutableOrderedSet(), isLoading: false }, blocks: { next: null, items: ImmutableOrderedSet(), isLoading: false },
mutes: { next: null, items: ImmutableOrderedSet(), isLoading: false }, mutes: { next: null, items: ImmutableOrderedSet(), isLoading: false },
directory: { next: null, items: ImmutableOrderedSet(), isLoading: true }, directory: { next: null, items: ImmutableOrderedSet(), isLoading: true },
groups: {},
groups_removed_accounts: {},
pinned: {}, pinned: {},
birthday_reminders: {}, birthday_reminders: {},
familiar_followers: {}, familiar_followers: {},

@ -1,58 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import {
GROUP_CREATE_REQUEST,
GROUP_CREATE_FAIL,
GROUP_CREATE_SUCCESS,
GROUP_UPDATE_REQUEST,
GROUP_UPDATE_FAIL,
GROUP_UPDATE_SUCCESS,
GROUP_EDITOR_RESET,
GROUP_EDITOR_SETUP,
GROUP_EDITOR_VALUE_CHANGE,
} from '../actions/group_editor';
const initialState = ImmutableMap({
groupId: null,
isSubmitting: false,
isChanged: false,
title: '',
description: '',
coverImage: null,
});
export default function groupEditorReducer(state = initialState, action) {
switch (action.type) {
case GROUP_EDITOR_RESET:
return initialState;
case GROUP_EDITOR_SETUP:
return state.withMutations(map => {
map.set('groupId', action.group.get('id'));
map.set('title', action.group.get('title'));
map.set('description', action.group.get('description'));
map.set('isSubmitting', false);
});
case GROUP_EDITOR_VALUE_CHANGE:
return state.withMutations(map => {
map.set(action.field, action.value);
map.set('isChanged', true);
});
case GROUP_CREATE_REQUEST:
case GROUP_UPDATE_REQUEST:
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case GROUP_CREATE_FAIL:
case GROUP_UPDATE_FAIL:
return state.set('isSubmitting', false);
case GROUP_CREATE_SUCCESS:
case GROUP_UPDATE_SUCCESS:
return state.withMutations(map => {
map.set('isSubmitting', false);
map.set('groupId', action.group.id);
});
default:
return state;
}
}

@ -1,22 +0,0 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { GROUPS_FETCH_SUCCESS } from '../actions/groups';
const initialState = ImmutableMap({
featured: ImmutableList(),
member: ImmutableList(),
admin: ImmutableList(),
});
const normalizeList = (state, type, id, groups) => {
return state.set(type, ImmutableList(groups.map(item => item.id)));
};
export default function groupLists(state = initialState, action) {
switch (action.type) {
case GROUPS_FETCH_SUCCESS:
return normalizeList(state, action.tab, action.id, action.groups);
default:
return state;
}
}

@ -1,27 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { GROUP_RELATIONSHIPS_FETCH_SUCCESS, GROUP_JOIN_SUCCESS, GROUP_LEAVE_SUCCESS } from '../actions/groups';
const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
const normalizeRelationships = (state, relationships) => {
relationships.forEach(relationship => {
state = normalizeRelationship(state, relationship);
});
return state;
};
const initialState = ImmutableMap();
export default function group_relationships(state = initialState, action) {
switch (action.type) {
case GROUP_JOIN_SUCCESS:
case GROUP_LEAVE_SUCCESS:
return normalizeRelationship(state, action.relationship);
case GROUP_RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships);
default:
return state;
}
}

@ -1,34 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { GROUP_UPDATE_SUCCESS } from '../actions/group_editor';
import {
GROUP_FETCH_SUCCESS,
GROUP_FETCH_FAIL,
GROUPS_FETCH_SUCCESS,
} from '../actions/groups';
const initialState = ImmutableMap();
const normalizeGroup = (state, group) => state.set(group.id, fromJS(group));
const normalizeGroups = (state, groups) => {
groups.forEach(group => {
state = normalizeGroup(state, group);
});
return state;
};
export default function groups(state = initialState, action) {
switch (action.type) {
case GROUP_FETCH_SUCCESS:
case GROUP_UPDATE_SUCCESS:
return normalizeGroup(state, action.group);
case GROUPS_FETCH_SUCCESS:
return normalizeGroups(state, action.groups);
case GROUP_FETCH_FAIL:
return state.set(action.id, false);
default:
return state;
}
}

@ -25,10 +25,6 @@ import custom_emojis from './custom_emojis';
import domain_lists from './domain_lists'; import domain_lists from './domain_lists';
import dropdown_menu from './dropdown_menu'; import dropdown_menu from './dropdown_menu';
import filters from './filters'; import filters from './filters';
import group_editor from './group_editor';
import group_lists from './group_lists';
import group_relationships from './group_relationships';
import groups from './groups';
import history from './history'; import history from './history';
import instance from './instance'; import instance from './instance';
import listAdder from './list_adder'; import listAdder from './list_adder';
@ -95,10 +91,6 @@ const reducers = {
suggestions, suggestions,
polls, polls,
trends, trends,
groups,
group_relationships,
group_lists,
group_editor,
sidebar, sidebar,
patron, patron,
soapbox, soapbox,

@ -3,6 +3,7 @@ import { Record as ImmutableRecord } from 'immutable';
import { import {
MUTES_INIT_MODAL, MUTES_INIT_MODAL,
MUTES_TOGGLE_HIDE_NOTIFICATIONS, MUTES_TOGGLE_HIDE_NOTIFICATIONS,
MUTES_CHANGE_DURATION,
} from '../actions/mutes'; } from '../actions/mutes';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
@ -11,6 +12,7 @@ const NewMuteRecord = ImmutableRecord({
isSubmitting: false, isSubmitting: false,
accountId: null, accountId: null,
notifications: true, notifications: true,
duration: 0,
}); });
const ReducerRecord = ImmutableRecord({ const ReducerRecord = ImmutableRecord({
@ -29,6 +31,8 @@ export default function mutes(state: State = ReducerRecord(), action: AnyAction)
}); });
case MUTES_TOGGLE_HIDE_NOTIFICATIONS: case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
return state.updateIn(['new', 'notifications'], (old) => !old); return state.updateIn(['new', 'notifications'], (old) => !old);
case MUTES_CHANGE_DURATION:
return state.setIn(['new', 'duration'], action.duration);
default: default:
return state; return state;
} }

@ -1,5 +1,5 @@
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import emojify from 'soapbox/features/emoji/emoji'; import emojify from 'soapbox/features/emoji/emoji';
import { normalizeStatus } from 'soapbox/normalizers'; import { normalizeStatus } from 'soapbox/normalizers';
@ -30,6 +30,8 @@ import {
STATUS_HIDE, STATUS_HIDE,
STATUS_DELETE_REQUEST, STATUS_DELETE_REQUEST,
STATUS_DELETE_FAIL, STATUS_DELETE_FAIL,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO,
} from '../actions/statuses'; } from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
@ -255,6 +257,10 @@ export default function statuses(state = initialState, action: AnyAction): State
return decrementReplyCount(state, action.params); return decrementReplyCount(state, action.params);
case STATUS_DELETE_FAIL: case STATUS_DELETE_FAIL:
return incrementReplyCount(state, action.params); return incrementReplyCount(state, action.params);
case STATUS_TRANSLATE_SUCCESS:
return state.setIn([action.id, 'translation'], fromJS(action.translation));
case STATUS_TRANSLATE_UNDO:
return state.deleteIn([action.id, 'translation']);
case TIMELINE_DELETE: case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references); return deleteStatus(state, action.id, action.references);
default: default:

@ -12,7 +12,6 @@ import {
ACCOUNT_MUTE_SUCCESS, ACCOUNT_MUTE_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS, ACCOUNT_UNFOLLOW_SUCCESS,
} from '../actions/accounts'; } from '../actions/accounts';
import { GROUP_REMOVE_STATUS_SUCCESS } from '../actions/groups';
import { import {
STATUS_CREATE_REQUEST, STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS, STATUS_CREATE_SUCCESS,
@ -210,10 +209,6 @@ const filterTimelines = (state: State, relationship: APIEntity, statuses: Immuta
}); });
}; };
const removeStatusFromGroup = (state: State, groupId: string, statusId: string) => {
return state.updateIn([`group:${groupId}`, 'items'], ImmutableOrderedSet(), ids => (ids as ImmutableOrderedSet<string>).delete(statusId));
};
const timelineDequeue = (state: State, timelineId: string) => { const timelineDequeue = (state: State, timelineId: string) => {
const top = state.getIn([timelineId, 'top']); const top = state.getIn([timelineId, 'top']);
@ -348,8 +343,6 @@ export default function timelines(state: State = initialState, action: AnyAction
return timelineConnect(state, action.timeline); return timelineConnect(state, action.timeline);
case TIMELINE_DISCONNECT: case TIMELINE_DISCONNECT:
return timelineDisconnect(state, action.timeline); return timelineDisconnect(state, action.timeline);
case GROUP_REMOVE_STATUS_SUCCESS:
return removeStatusFromGroup(state, action.groupId, action.id);
case TIMELINE_REPLACE: case TIMELINE_REPLACE:
return state return state
.update('home', TimelineRecord(), timeline => timeline.withMutations(timeline => { .update('home', TimelineRecord(), timeline => timeline.withMutations(timeline => {

@ -32,13 +32,6 @@ import {
import { import {
FAMILIAR_FOLLOWERS_FETCH_SUCCESS, FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
} from '../actions/familiar_followers'; } from '../actions/familiar_followers';
import {
GROUP_MEMBERS_FETCH_SUCCESS,
GROUP_MEMBERS_EXPAND_SUCCESS,
GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS,
GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS,
GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS,
} from '../actions/groups';
import { import {
REBLOGS_FETCH_SUCCESS, REBLOGS_FETCH_SUCCESS,
FAVOURITES_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS,
@ -82,8 +75,6 @@ export const ReducerRecord = ImmutableRecord({
blocks: ListRecord(), blocks: ListRecord(),
mutes: ListRecord(), mutes: ListRecord(),
directory: ListRecord({ isLoading: true }), directory: ListRecord({ isLoading: true }),
groups: ImmutableMap<string, List>(),
groups_removed_accounts: ImmutableMap<string, List>(),
pinned: ImmutableMap<string, List>(), pinned: ImmutableMap<string, List>(),
birthday_reminders: ImmutableMap<string, List>(), birthday_reminders: ImmutableMap<string, List>(),
familiar_followers: ImmutableMap<string, List>(), familiar_followers: ImmutableMap<string, List>(),
@ -94,7 +85,7 @@ 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>;
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'groups' | 'groups_removed_accounts' | 'pinned' | 'birthday_reminders' | 'familiar_followers', string]; type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers', string];
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory']; type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => { const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
@ -170,16 +161,6 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) {
case DIRECTORY_FETCH_FAIL: case DIRECTORY_FETCH_FAIL:
case DIRECTORY_EXPAND_FAIL: case DIRECTORY_EXPAND_FAIL:
return state.setIn(['directory', 'isLoading'], false); return state.setIn(['directory', 'isLoading'], false);
case GROUP_MEMBERS_FETCH_SUCCESS:
return normalizeList(state, ['groups', action.id], action.accounts, action.next);
case GROUP_MEMBERS_EXPAND_SUCCESS:
return appendToList(state, ['groups', action.id], action.accounts, action.next);
case GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS:
return normalizeList(state, ['groups_removed_accounts', action.id], action.accounts, action.next);
case GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS:
return appendToList(state, ['groups_removed_accounts', action.id], action.accounts, action.next);
case GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS:
return removeFromList(state, ['groups_removed_accounts', action.groupId], action.id);
case PINNED_ACCOUNTS_FETCH_SUCCESS: case PINNED_ACCOUNTS_FETCH_SUCCESS:
return normalizeList(state, ['pinned', action.id], action.accounts, action.next); return normalizeList(state, ['pinned', action.id], action.accounts, action.next);
case BIRTHDAY_REMINDERS_FETCH_SUCCESS: case BIRTHDAY_REMINDERS_FETCH_SUCCESS:

@ -424,6 +424,15 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
muteStrangers: v.software === PLEROMA, muteStrangers: v.software === PLEROMA,
/**
* Ability to specify how long the account mute should last.
* @see PUT /api/v1/accounts/:id/mute
*/
mutesDuration: any([
v.software === PLEROMA && gte(v.version, '2.3.0'),
v.software === MASTODON && gte(v.compatVersion, '3.3.0'),
]),
/** /**
* Add private notes to accounts. * Add private notes to accounts.
* @see POST /api/v1/accounts/:id/note * @see POST /api/v1/accounts/:id/note
@ -639,6 +648,12 @@ const getInstanceFeatures = (instance: Instance) => {
features.includes('v2_suggestions'), features.includes('v2_suggestions'),
]), ]),
/**
* Can translate statuses.
* @see POST /api/v1/statuses/:id/translate
*/
translations: features.includes('translation'),
/** /**
* Trending statuses. * Trending statuses.
* @see GET /api/v1/trends/statuses * @see GET /api/v1/trends/statuses

@ -14,6 +14,12 @@ const NOTIFICATION_TYPES = [
'update', 'update',
] as const; ] as const;
/** Notification types to exclude from the "All" filter by default. */
const EXCLUDE_TYPES = [
'pleroma:chat_mention',
'chat', // TruthSocial
] as const;
type NotificationType = typeof NOTIFICATION_TYPES[number]; type NotificationType = typeof NOTIFICATION_TYPES[number];
/** Ensure the Notification is a valid, known type. */ /** Ensure the Notification is a valid, known type. */
@ -21,6 +27,7 @@ const validType = (type: string): type is NotificationType => NOTIFICATION_TYPES
export { export {
NOTIFICATION_TYPES, NOTIFICATION_TYPES,
EXCLUDE_TYPES,
NotificationType, NotificationType,
validType, validType,
}; };

@ -0,0 +1,15 @@
/** Unregister the ServiceWorker */
// https://stackoverflow.com/a/49771828/8811886
const unregisterSw = async(): Promise<void> => {
if (navigator.serviceWorker) {
// FIXME: this only works if using a single tab.
// Send a message to sw.js instead to refresh all tabs.
const registrations = await navigator.serviceWorker.getRegistrations();
const unregisterAll = registrations.map(r => r.unregister());
await Promise.all(unregisterAll);
}
};
export {
unregisterSw,
};

@ -102,7 +102,7 @@
} }
.status-card { .status-card {
@apply flex text-sm border border-solid border-gray-200 dark:border-gray-800 rounded-lg text-gray-800 dark:text-gray-200 mt-3 min-h-[150px] no-underline overflow-hidden; @apply flex text-sm border border-solid border-gray-200 dark:border-gray-800 rounded-lg text-gray-800 dark:text-gray-200 min-h-[150px] no-underline overflow-hidden;
} }
a.status-card { a.status-card {

@ -17,7 +17,6 @@ module.exports = (api) => {
plugins: [ plugins: [
'@babel/syntax-dynamic-import', '@babel/syntax-dynamic-import',
['@babel/proposal-object-rest-spread', { useBuiltIns: true }], ['@babel/proposal-object-rest-spread', { useBuiltIns: true }],
['@babel/proposal-decorators', { legacy: true }],
'@babel/proposal-class-properties', '@babel/proposal-class-properties',
['react-intl', { messagesDir: './build/messages/' }], ['react-intl', { messagesDir: './build/messages/' }],
'preval', 'preval',

@ -39,7 +39,6 @@
"dependencies": { "dependencies": {
"@babel/core": "^7.18.2", "@babel/core": "^7.18.2",
"@babel/plugin-proposal-class-properties": "^7.17.12", "@babel/plugin-proposal-class-properties": "^7.17.12",
"@babel/plugin-proposal-decorators": "^7.18.2",
"@babel/plugin-proposal-object-rest-spread": "^7.18.0", "@babel/plugin-proposal-object-rest-spread": "^7.18.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-react-inline-elements": "^7.16.7", "@babel/plugin-transform-react-inline-elements": "^7.16.7",
@ -67,7 +66,7 @@
"@sentry/browser": "^7.11.1", "@sentry/browser": "^7.11.1",
"@sentry/react": "^7.11.1", "@sentry/react": "^7.11.1",
"@sentry/tracing": "^7.11.1", "@sentry/tracing": "^7.11.1",
"@tabler/icons": "^1.73.0", "@tabler/icons": "^1.109.0",
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.7", "@tailwindcss/typography": "^0.5.7",
"@tanstack/react-query": "^4.0.10", "@tanstack/react-query": "^4.0.10",
@ -167,6 +166,7 @@
"react-inlinesvg": "^3.0.0", "react-inlinesvg": "^3.0.0",
"react-intl": "^5.0.0", "react-intl": "^5.0.0",
"react-motion": "^0.5.2", "react-motion": "^0.5.2",
"react-notification": "^6.8.5",
"react-otp-input": "^2.4.0", "react-otp-input": "^2.4.0",
"react-overlays": "^0.9.0", "react-overlays": "^0.9.0",
"react-popper": "^2.3.0", "react-popper": "^2.3.0",

@ -11,7 +11,6 @@
"allowJs": true, "allowJs": true,
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"experimentalDecorators": true,
"esModuleInterop": true, "esModuleInterop": true,
"typeRoots": [ "./types", "./node_modules/@types"] "typeRoots": [ "./types", "./node_modules/@types"]
}, },

@ -538,18 +538,6 @@
"@babel/helper-plugin-utils" "^7.17.12" "@babel/helper-plugin-utils" "^7.17.12"
"@babel/plugin-syntax-class-static-block" "^7.14.5" "@babel/plugin-syntax-class-static-block" "^7.14.5"
"@babel/plugin-proposal-decorators@^7.18.2":
version "7.18.2"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.18.2.tgz#dbe4086d2d42db489399783c3aa9272e9700afd4"
integrity sha512-kbDISufFOxeczi0v4NQP3p5kIeW6izn/6klfWBrIIdGZZe4UpHR+QU03FAoWjGGd9SUXAwbw2pup1kaL4OQsJQ==
dependencies:
"@babel/helper-create-class-features-plugin" "^7.18.0"
"@babel/helper-plugin-utils" "^7.17.12"
"@babel/helper-replace-supers" "^7.18.2"
"@babel/helper-split-export-declaration" "^7.16.7"
"@babel/plugin-syntax-decorators" "^7.17.12"
charcodes "^0.2.0"
"@babel/plugin-proposal-dynamic-import@^7.16.7": "@babel/plugin-proposal-dynamic-import@^7.16.7":
version "7.16.7" version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.16.7.tgz#c19c897eaa46b27634a00fee9fb7d829158704b2" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.16.7.tgz#c19c897eaa46b27634a00fee9fb7d829158704b2"
@ -688,13 +676,6 @@
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.14.5" "@babel/helper-plugin-utils" "^7.14.5"
"@babel/plugin-syntax-decorators@^7.17.12":
version "7.17.12"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.17.12.tgz#02e8f678602f0af8222235271efea945cfdb018a"
integrity sha512-D1Hz0qtGTza8K2xGyEdVNCYLdVHukAcbQr4K3/s6r/esadyEriZovpJimQOpu8ju4/jV8dW/1xdaE0UpDroidw==
dependencies:
"@babel/helper-plugin-utils" "^7.17.12"
"@babel/plugin-syntax-dynamic-import@^7.8.3": "@babel/plugin-syntax-dynamic-import@^7.8.3":
version "7.8.3" version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"
@ -2290,10 +2271,10 @@
remark "^13.0.0" remark "^13.0.0"
unist-util-find-all-after "^3.0.2" unist-util-find-all-after "^3.0.2"
"@tabler/icons@^1.73.0": "@tabler/icons@^1.109.0":
version "1.73.0" version "1.109.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.73.0.tgz#26d81858baf41be939504e1f9b4b32835eda6fdb" resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.109.0.tgz#11626c3fc097f2f70c4c197e4b9909fb05380752"
integrity sha512-MhAHFzVj79ZWlAIRD++7Mk55PZsdlEdkfkjO3DD257mqj8iJZQRAQtkx2UFJXVs2mMrcOUu1qtj4rlVC8BfnKA== integrity sha512-B0YetE4pB6HY2Wa57v/LJ3NgkJzKYPze4U0DurIqPoKSptatKv2ga76FZSkO6EUpkYfHMtGPM6QjpJljfuCmAQ==
"@tailwindcss/forms@^0.5.3": "@tailwindcss/forms@^0.5.3":
version "0.5.3" version "0.5.3"
@ -4196,11 +4177,6 @@ character-reference-invalid@^1.0.0:
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560"
integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==
charcodes@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/charcodes/-/charcodes-0.2.0.tgz#5208d327e6cc05f99eb80ffc814707572d1f14e4"
integrity sha512-Y4kiDb+AM4Ecy58YkuZrrSRJBDQdQ2L+NyS1vHHFtNtUjgutcZfx3yp1dAONI/oPaPmyGfCLx5CxL+zauIMyKQ==
cheerio-select@^1.5.0: cheerio-select@^1.5.0:
version "1.5.0" version "1.5.0"
resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.5.0.tgz#faf3daeb31b17c5e1a9dabcee288aaf8aafa5823" resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.5.0.tgz#faf3daeb31b17c5e1a9dabcee288aaf8aafa5823"
@ -9946,6 +9922,13 @@ react-motion@^0.5.2:
prop-types "^15.5.8" prop-types "^15.5.8"
raf "^3.1.0" raf "^3.1.0"
react-notification@^6.8.5:
version "6.8.5"
resolved "https://registry.yarnpkg.com/react-notification/-/react-notification-6.8.5.tgz#7ea90a633bb2a280d899e30c93cf372265cce4f0"
integrity sha512-3pJPhSsWNYizpyeMeWuC+jVthqE9WKqQ6rHq2naiiP4fLGN4irwL2Xp2Q8Qn7agW/e4BIDxarab6fJOUp1cKUw==
dependencies:
prop-types "^15.6.2"
react-onclickoutside@^6.12.0: react-onclickoutside@^6.12.0:
version "6.12.1" version "6.12.1"
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.12.1.tgz#92dddd28f55e483a1838c5c2930e051168c1e96b" resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.12.1.tgz#92dddd28f55e483a1838c5c2930e051168c1e96b"

Loading…
Cancel
Save