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

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-11-12 16:48:27 +01:00
commit 7b2193d753
135 changed files with 5452 additions and 7566 deletions

18
Dockerfile.dev Normal file
View File

@ -0,0 +1,18 @@
FROM node:18
RUN apt-get update &&\
apt-get install -y inotify-tools &&\
# clean up apt
rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=development
COPY package.json .
COPY yarn.lock .
RUN yarn
COPY . .
ENV DEVSERVER_URL=http://0.0.0.0:3036
CMD yarn dev

View File

@ -8,7 +8,6 @@
<meta name="referrer" content="same-origin" />
<link href="/manifest.json" rel="manifest">
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
<%= snippets %>
</head>
<body class="theme-mode-light no-reduce-motion">

View File

@ -0,0 +1,105 @@
{
"approval_required": false,
"avatar_upload_limit": 2000000,
"background_image": "https://fe.disroot.org/images/city.jpg",
"background_upload_limit": 4000000,
"banner_upload_limit": 4000000,
"description": "FEDIsroot - Federated social network powered by Pleroma (open beta)",
"description_limit": 5000,
"email": "admin@example.lan",
"languages": [
"en"
],
"max_toot_chars": 5000,
"pleroma": {
"metadata": {
"account_activation_required": false,
"features": [
"pleroma_api",
"akkoma_api",
"mastodon_api",
"mastodon_api_streaming",
"polls",
"v2_suggestions",
"pleroma_explicit_addressing",
"shareable_emoji_packs",
"multifetch",
"pleroma:api/v1/notifications:include_types_filter",
"editing",
"media_proxy",
"relay",
"pleroma_emoji_reactions",
"exposable_reactions",
"profile_directory",
"custom_emoji_reactions",
"pleroma:get:main/ostatus"
],
"federation": {
"enabled": true,
"exclusions": false,
"mrf_hashtag": {
"federated_timeline_removal": [],
"reject": [],
"sensitive": [
"nsfw"
]
},
"mrf_object_age": {
"actions": [
"delist",
"strip_followers"
],
"threshold": 604800
},
"mrf_policies": [
"ObjectAgePolicy",
"TagPolicy",
"HashtagPolicy",
"InlineQuotePolicy"
],
"quarantined_instances": [],
"quarantined_instances_info": {
"quarantined_instances": {}
}
},
"fields_limits": {
"max_fields": 10,
"max_remote_fields": 20,
"name_length": 512,
"value_length": 2048
},
"post_formats": [
"text/plain",
"text/html",
"text/markdown",
"text/bbcode",
"text/x.misskeymarkdown"
],
"privileged_staff": false
},
"stats": {
"mau": 83
},
"vapid_public_key": null
},
"poll_limits": {
"max_expiration": 31536000,
"max_option_chars": 200,
"max_options": 20,
"min_expiration": 0
},
"registrations": false,
"stats": {
"domain_count": 6972,
"status_count": 8081,
"user_count": 357
},
"thumbnail": "https://fe.disroot.org/instance/thumbnail.jpeg",
"title": "FEDIsroot",
"upload_limit": 16000000,
"uri": "https://fe.disroot.org",
"urls": {
"streaming_api": "wss://fe.disroot.org"
},
"version": "2.7.2 (compatible; Akkoma 3.3.1-0-gaf90a4e51)"
}

View File

@ -1,5 +1,5 @@
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';
@ -359,14 +359,30 @@ const unblockAccountFail = (error: AxiosError) => ({
error,
});
const muteAccount = (id: string, notifications?: boolean) =>
const muteAccount = (id: string, notifications?: boolean, duration = 0) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
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)
.post(`/api/v1/accounts/${id}/mute`, { notifications })
.post(`/api/v1/accounts/${id}/mute`, params)
.then(response => {
// 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));

View File

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

View File

@ -55,7 +55,6 @@ const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE';
const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
@ -601,11 +600,6 @@ const insertIntoTagHistory = (composeId: string, recognizedTags: APIEntity[], te
dispatch(updateTagHistory(composeId, newHistory));
};
const changeComposeSensitivity = (composeId: string) => ({
type: COMPOSE_SENSITIVITY_CHANGE,
id: composeId,
});
const changeComposeSpoilerness = (composeId: string) => ({
type: COMPOSE_SPOILERNESS_CHANGE,
id: composeId,
@ -758,7 +752,6 @@ export {
COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_TYPE_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
@ -813,7 +806,6 @@ export {
selectComposeSuggestion,
updateSuggestionTags,
updateTagHistory,
changeComposeSensitivity,
changeComposeSpoilerness,
changeComposeContentType,
changeComposeSpoilerText,

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import {
List as ImmutableList,
Map as ImmutableMap,
} from 'immutable';
import IntlMessageFormat from 'intl-messageformat';
@ -12,6 +11,7 @@ import { getFilters, regexFromFilters } from 'soapbox/selectors';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features';
import { unescapeHTML } from 'soapbox/utils/html';
import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from 'soapbox/utils/notification';
import { joinPublicPath } from 'soapbox/utils/static';
import { fetchRelationships } from './accounts';
@ -168,11 +168,8 @@ const dequeueNotifications = () =>
dispatch(markReadNotifications());
};
// const excludeTypesFromSettings = (getState: () => RootState) => (getSettings(getState()).getIn(['notifications', 'shows']) as ImmutableMap<string, boolean>).filter(enabled => !enabled).keySeq().toJS();
const excludeTypesFromFilter = (filter: string) => {
const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'status', 'poll', 'move', 'pleroma:emoji_reaction']);
return allTypes.filterNot(item => item === filter).toJS();
return NOTIFICATION_TYPES.filter(item => item !== filter);
};
const noOp = () => new Promise(f => f(undefined));
@ -182,6 +179,7 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => an
if (!isLoggedIn(getState)) return dispatch(noOp);
const state = getState();
const features = getFeatures(state.instance);
const activeFilter = getSettings(state).getIn(['notifications', 'quickFilter', 'active']) as string;
const notifications = state.notifications;
const isLoadingMore = !!maxId;
@ -195,10 +193,13 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => an
max_id: maxId,
};
if (activeFilter !== 'all') {
const instance = state.instance;
const features = getFeatures(instance);
if (activeFilter === 'all') {
if (features.notificationsIncludeTypes) {
params.types = NOTIFICATION_TYPES.filter(type => !EXCLUDE_TYPES.includes(type as any));
} else {
params.exclude_types = EXCLUDE_TYPES;
}
} else {
if (features.notificationsIncludeTypes) {
params.types = [activeFilter];
} else {

View File

@ -43,6 +43,11 @@ const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
const STATUS_REVEAL = 'STATUS_REVEAL';
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) => {
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 {
STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS,
@ -329,6 +359,10 @@ export {
STATUS_UNMUTE_FAIL,
STATUS_REVEAL,
STATUS_HIDE,
STATUS_TRANSLATE_REQUEST,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO,
createStatus,
editStatus,
fetchStatus,
@ -345,4 +379,6 @@ export {
hideStatus,
revealStatus,
toggleStatusHidden,
translateStatus,
undoStatusTranslation,
};

View File

@ -0,0 +1,42 @@
import React from 'react';
import { render, screen, rootState } from '../../jest/test-helpers';
import { normalizeStatus, normalizeAccount } from '../../normalizers';
import Status from '../status';
import type { ReducerStatus } from 'soapbox/reducers/statuses';
const account = normalizeAccount({
id: '1',
acct: 'alex',
});
const status = normalizeStatus({
id: '1',
account,
content: 'hello world',
contentHtml: 'hello world',
}) as ReducerStatus;
describe('<Status />', () => {
const state = rootState.setIn(['accounts', '1'], account);
it('renders content', () => {
render(<Status status={status} />, undefined, state);
screen.getByText(/hello world/i);
expect(screen.getByTestId('status')).toHaveTextContent(/hello world/i);
});
describe('the Status Action Bar', () => {
it('is rendered', () => {
render(<Status status={status} />, undefined, state);
expect(screen.getByTestId('status-action-bar')).toBeInTheDocument();
});
it('is not rendered if status is under review', () => {
const inReviewStatus = normalizeStatus({ ...status, visibility: 'self' });
render(<Status status={inReviewStatus as ReducerStatus} />, undefined, state);
expect(screen.queryAllByTestId('status-action-bar')).toHaveLength(0);
});
});
});

View File

@ -22,7 +22,13 @@ const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account }) => {
const handleClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
history.push(`/timeline/${account.domain}`);
const timelineUrl = `/timeline/${account.domain}`;
if (!(e.ctrlKey || e.metaKey)) {
history.push(timelineUrl);
} else {
window.open(timelineUrl, '_blank');
}
};
return (
@ -221,7 +227,7 @@ const Account = ({
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
{timestampUrl ? (
<Link to={timestampUrl} className='hover:underline'>
<Link to={timestampUrl} className='hover:underline' onClick={(event) => event.stopPropagation()}>
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
</Link>
) : (
@ -237,6 +243,14 @@ const Account = ({
<Icon className='h-5 w-5 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/pencil.svg')} />
</>
) : 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>
{note ? (

View File

@ -1,4 +1,4 @@
import Portal from '@reach/portal';
import { Portal } from '@reach/portal';
import classNames from 'clsx';
import { List as ImmutableList } from 'immutable';
import React from 'react';
@ -45,7 +45,7 @@ const textAtCursorMatchesToken = (str: string, caretPosition: number, searchToke
}
};
interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> {
export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> {
value: string,
suggestions: ImmutableList<any>,
disabled?: boolean,

View File

@ -1,4 +1,4 @@
import Portal from '@reach/portal';
import { Portal } from '@reach/portal';
import classNames from 'clsx';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';

View File

@ -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>
);
}
}

View File

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

View File

@ -143,12 +143,14 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
this.props.onClose();
if (typeof action === 'function') {
e.preventDefault();
action(e);
} else if (to) {
e.stopPropagation();
if (to) {
e.preventDefault();
this.props.history.push(to);
} else if (typeof action === 'function') {
e.preventDefault();
action(e);
}
}
@ -192,6 +194,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
data-index={i}
target={newTab ? '_blank' : undefined}
data-method={isLogout ? 'delete' : undefined}
title={text}
>
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}

View File

@ -8,6 +8,7 @@ import { Text, Stack } from 'soapbox/components/ui';
import { captureException } from 'soapbox/monitoring';
import KVStore from 'soapbox/storage/kv_store';
import sourceCode from 'soapbox/utils/code';
import { unregisterSw } from 'soapbox/utils/sw';
import SiteLogo from './site-logo';
@ -15,16 +16,6 @@ import type { RootState } from 'soapbox/store';
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 { links, logo } = getSoapboxConfig(state);

View File

@ -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>
);
}
}

View File

@ -47,12 +47,13 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
const isEditing = useAppSelector(state => state.compose.get('compose-modal')?.id !== null);
const handleKeyUp = useCallback((e) => {
if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
&& !!children) {
const visible = !!children;
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) {
handleOnClose();
}
}, []);
};
const handleOnClose = () => {
dispatch((_, getState) => {
@ -136,6 +137,8 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
};
useEffect(() => {
if (!visible) return;
window.addEventListener('keyup', handleKeyUp, false);
window.addEventListener('keydown', handleKeyDown, false);
@ -143,7 +146,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
}, [visible]);
useEffect(() => {
if (!!children && !prevChildren) {
@ -172,8 +175,6 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
}
});
const visible = !!children;
if (!visible) {
return (
<div className='z-50 transition-all' ref={ref} style={{ opacity: 0 }} />

View File

@ -1,16 +1,19 @@
import classNames from 'clsx';
import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage, FormattedList } from 'react-intl';
import React, { MouseEventHandler, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import StatusMedia from 'soapbox/components/status-media';
import { Stack, Text } from 'soapbox/components/ui';
import { Stack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useSettings } from 'soapbox/hooks';
import { defaultMediaVisibility } from 'soapbox/utils/status';
import EventPreview from './event-preview';
import OutlineBox from './outline-box';
import StatusReplyMentions from './status-reply-mentions';
import StatusContent from './status_content';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
@ -37,12 +40,17 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
const [showMedia, setShowMedia] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
const handleExpandClick = (e: React.MouseEvent<HTMLDivElement>) => {
const handleExpandClick: MouseEventHandler<HTMLDivElement> = (e) => {
if (!status) return;
const account = status.account as AccountEntity;
if (!compose && e.button === 0) {
history.push(`/@${account.acct}/posts/${status.id}`);
const statusUrl = `/@${account.acct}/posts/${status.id}`;
if (!(e.ctrlKey || e.metaKey)) {
history.push(statusUrl);
} else {
window.open(statusUrl, '_blank');
}
e.stopPropagation();
e.preventDefault();
}
@ -58,57 +66,6 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
setShowMedia(!showMedia);
};
const renderReplyMentions = () => {
if (!status?.in_reply_to_id) {
return null;
}
const account = status.account as AccountEntity;
const to = status.mentions || [];
if (to.size === 0) {
if (status.in_reply_to_account_id === account.id) {
return (
<div className='reply-mentions'>
<FormattedMessage
id='reply_mentions.reply'
defaultMessage='Replying to {accounts}'
values={{
accounts: `@${account.username}`,
}}
/>
</div>
);
} else {
return (
<div className='reply-mentions'>
<FormattedMessage id='reply_mentions.reply_empty' defaultMessage='Replying to post' />
</div>
);
}
}
const accounts = to.slice(0, 2).map(account => <>@{account.username}</>).toArray();
if (to.size > 2) {
accounts.push(
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />,
);
}
return (
<div className='reply-mentions'>
<FormattedMessage
id='reply_mentions.reply'
defaultMessage='Replying to {accounts}'
values={{
accounts: <FormattedList type='conjunction' value={accounts} />,
}}
/>
</div>
);
};
if (!status) {
return null;
}
@ -128,7 +85,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
return (
<OutlineBox
data-testid='quoted-status'
className={classNames('mt-3 cursor-pointer', {
className={classNames('cursor-pointer', {
'hover:bg-gray-100 dark:hover:bg-gray-800': !compose,
})}
>
@ -145,23 +102,37 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
withLinkToProfile={!compose}
/>
{renderReplyMentions()}
<StatusReplyMentions status={status} hoverable={false} />
{status.event ? <EventPreview status={status} hideAction /> : (
<>
<Text
className='break-words status__content status__content--quote'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
<Stack className={classNames('relative', {
'min-h-[220px]': status.hidden,
})}
>
{(status.hidden) && (
<SensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
)}
<Stack space={4}>
<StatusContent
status={status}
collapsable
/>
{(status.card || status.media_attachments.size > 0) && (
<StatusMedia
status={status}
muted={compose}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
</>
)}
</Stack>
</Stack>
)}
</Stack>
</OutlineBox>

View File

@ -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>
);
}
}

View File

@ -37,16 +37,17 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
ref={ref}
onClick={handleClick}
className={classNames({
'flex items-center px-4 py-3.5 text-base font-semibold space-x-4 rounded-full group text-gray-600 hover:text-primary-600 dark:text-gray-500 dark:hover:text-gray-100 hover:bg-primary-100 dark:hover:bg-primary-700': true,
'dark:text-gray-100 text-primary-600': isActive,
'flex items-center px-4 py-3.5 text-base font-semibold space-x-4 rounded-full group text-gray-600 hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-100 hover:bg-primary-200 dark:hover:bg-primary-900': true,
'dark:text-gray-100 text-gray-900': isActive,
})}
>
<span className='relative'>
<Icon
src={icon}
count={count}
className={classNames('h-5 w-5 group-hover:text-primary-500', {
'text-primary-500': isActive,
className={classNames('h-5 w-5', {
'text-gray-600 dark:text-gray-500 group-hover:text-primary-500 dark:group-hover:text-primary-400': !isActive,
'text-primary-500 dark:text-primary-400': isActive,
})}
/>
</span>

View File

@ -461,7 +461,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
text: intl.formatMessage(messages.admin_status),
href: `/pleroma/admin/#/statuses/${status.id}/`,
icon: require('@tabler/icons/pencil.svg'),
action: (event) => event.stopPropagation(),
});
}
@ -554,6 +553,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
return (
<div
data-testid='status-action-bar'
className={classNames('flex flex-row', {
'justify-between': space === 'expand',
'space-x-2': space === 'compact',

View File

@ -1,9 +1,9 @@
.status-content p {
@apply mb-5 whitespace-pre-wrap;
@apply mb-4 whitespace-pre-wrap;
}
.status-content p:last-child {
@apply mb-0.5;
@apply mb-0;
}
.status-content a {
@ -20,7 +20,7 @@
.status-content ul,
.status-content ol {
@apply pl-10 mb-5;
@apply pl-10 mb-4;
}
.status-content ul {
@ -32,7 +32,7 @@
}
.status-content blockquote {
@apply py-1 pl-4 mb-5 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400;
@apply py-1 pl-4 mb-4 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400;
}
.status-content code {
@ -51,7 +51,7 @@
/* Code block */
.status-content pre {
@apply py-2 px-3 mb-5 leading-6 overflow-x-auto rounded-md break-all;
@apply py-2 px-3 mb-4 leading-6 overflow-x-auto rounded-md break-all;
}
.status-content pre:last-child {
@ -60,7 +60,7 @@
/* Markdown images */
.status-content img:not(.emojione):not([width][height]) {
@apply w-full h-72 object-contain rounded-lg overflow-hidden my-5 block;
@apply w-full h-72 object-contain rounded-lg overflow-hidden my-4 block;
}
/* User setting to underline links */

View File

@ -50,7 +50,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
// The typical case with a reply-to and a list of mentions.
const accounts = to.slice(0, 2).map(account => {
const link = (
<Link to={`/@${account.acct}`} className='reply-mentions__account'>@{account.username}</Link>
<Link to={`/@${account.acct}`} className='reply-mentions__account' onClick={(e) => e.stopPropagation()}>@{account.username}</Link>
);
if (hoverable) {

View File

@ -9,6 +9,7 @@ import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon';
import TranslateButton from 'soapbox/components/translate-button';
import AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
import { useAppDispatch, useSettings } from 'soapbox/hooks';
@ -66,7 +67,6 @@ const Status: React.FC<IStatus> = (props) => {
hidden,
featured,
unread,
group,
hideActionBar,
variant = 'rounded',
withDismiss,
@ -85,6 +85,8 @@ const Status: React.FC<IStatus> = (props) => {
const actualStatus = getActualStatus(status);
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
// Track height changes we know about to compensate scrolling.
useEffect(() => {
didShowCard.current = Boolean(!muted && !hidden && status?.card);
@ -98,16 +100,18 @@ const Status: React.FC<IStatus> = (props) => {
setShowMedia(!showMedia);
};
const handleClick = (): void => {
const handleClick = (e?: React.MouseEvent): void => {
e?.stopPropagation();
if (!e || !(e.ctrlKey || e.metaKey)) {
if (onClick) {
onClick();
} else {
history.push(`/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`);
history.push(statusUrl);
}
} else {
window.open(statusUrl, '_blank');
}
};
const handleExpandedToggle = (): void => {
dispatch(toggleStatusHidden(actualStatus));
};
const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => {
@ -150,7 +154,7 @@ const Status: React.FC<IStatus> = (props) => {
};
const handleHotkeyOpen = (): void => {
history.push(`/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`);
history.push(statusUrl);
};
const handleHotkeyOpenProfile = (): void => {
@ -297,12 +301,10 @@ const Status: React.FC<IStatus> = (props) => {
react: handleHotkeyReact,
};
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
const accountAction = props.accountAction || reblogElement;
const inReview = status.visibility === 'self';
const isSensitive = status.sensitive;
const isUnderReview = actualStatus.visibility === 'self';
const isSensitive = actualStatus.hidden;
return (
<HotKeys handlers={handlers} data-testid='status'>
@ -312,7 +314,7 @@ const Status: React.FC<IStatus> = (props) => {
data-featured={featured ? 'true' : null}
aria-label={textForScreenReader(intl, actualStatus, rebloggedByText)}
ref={node}
onClick={() => history.push(statusUrl)}
onClick={handleClick}
role='link'
>
{featured && (
@ -354,42 +356,36 @@ const Status: React.FC<IStatus> = (props) => {
</div>
<div className='status__content-wrapper'>
<StatusReplyMentions status={actualStatus} hoverable={hoverable} />
<Stack
className={
classNames('relative', {
'min-h-[220px]': inReview || isSensitive,
'min-h-[220px]': isUnderReview || isSensitive,
})
}
>
{(inReview || isSensitive) ? (
{(isUnderReview || isSensitive) && (
<SensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
) : null}
{!group && actualStatus.group && (
<div className='status__meta'>
Posted in <NavLink to={`/groups/${actualStatus.getIn(['group', 'id'])}`}>{String(actualStatus.getIn(['group', 'title']))}</NavLink>
</div>
)}
<StatusReplyMentions
status={actualStatus}
hoverable={hoverable}
/>
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
<>
<StatusContent
status={actualStatus}
onClick={handleClick}
expanded={!status.hidden}
onExpandedToggle={handleExpandedToggle}
collapsable
translatable
/>
<TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}>
<StatusMedia
status={actualStatus}
muted={muted}
@ -399,11 +395,13 @@ const Status: React.FC<IStatus> = (props) => {
/>
{quote}
</Stack>
)}
</>
)}
</Stack>
{!hideActionBar && (
{(!hideActionBar && !isUnderReview) && (
<div className='pt-4'>
<StatusActionBar status={actualStatus} withDismiss={withDismiss} />
</div>

View File

@ -35,49 +35,17 @@ const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick }) => (
</button>
);
interface ISpoilerButton {
onClick: React.MouseEventHandler,
hidden: boolean,
tabIndex?: number,
}
/** Button to expand status text behind a content warning */
const SpoilerButton: React.FC<ISpoilerButton> = ({ onClick, hidden, tabIndex }) => (
<button
tabIndex={tabIndex}
className={classNames(
'inline-block rounded-md px-1.5 py-0.5 ml-[0.5em]',
'text-gray-900 dark:text-gray-100',
'font-bold text-[11px] uppercase',
'bg-primary-100 dark:bg-primary-800',
'hover:bg-primary-300 dark:hover:bg-primary-600',
'focus:bg-primary-200 dark:focus:bg-primary-600',
'hover:no-underline',
'duration-100',
)}
onClick={onClick}
>
{hidden ? (
<FormattedMessage id='status.show_more' defaultMessage='Show more' />
) : (
<FormattedMessage id='status.show_less' defaultMessage='Show less' />
)}
</button>
);
interface IStatusContent {
status: Status,
expanded?: boolean,
onExpandedToggle?: () => void,
onClick?: () => void,
collapsable?: boolean,
translatable?: boolean,
}
/** Renders the text content of a status */
const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onExpandedToggle, onClick, collapsable = false }) => {
const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable = false, translatable }) => {
const history = useHistory();
const [hidden, setHidden] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const [onlyEmoji, setOnlyEmoji] = useState(false);
@ -179,46 +147,32 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && onClick) {
if (deltaX + deltaY < 5 && e.button === 0 && !(e.ctrlKey || e.metaKey) && onClick) {
onClick();
}
startXY.current = undefined;
};
const handleSpoilerClick: React.EventHandler<React.MouseEvent> = (e) => {
e.preventDefault();
e.stopPropagation();
if (onExpandedToggle) {
// The parent manages the state
onExpandedToggle();
} else {
setHidden(!hidden);
}
};
const parsedHtml = useMemo((): string => {
const { contentHtml: html } = status;
const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
if (greentext) {
return addGreentext(html);
} else {
return html;
}
}, [status.contentHtml]);
}, [status.contentHtml, status.translation]);
if (status.content.length === 0) {
return null;
}
const isHidden = onExpandedToggle ? !expanded : hidden;
const withSpoiler = status.spoiler_text.length > 0;
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
const content = { __html: parsedHtml };
const spoilerContent = { __html: status.spoilerHtml };
const directionStyle: React.CSSProperties = { direction: 'ltr' };
const className = classNames(baseClassName, 'status-content', {
'cursor-pointer': onClick,
@ -231,37 +185,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
directionStyle.direction = 'rtl';
}
if (status.spoiler_text.length > 0) {
return (
<div className={className} ref={node} tabIndex={0} style={directionStyle} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
<p style={{ marginBottom: isHidden && status.mentions.isEmpty() ? 0 : undefined }}>
<span dangerouslySetInnerHTML={spoilerContent} lang={status.language || undefined} />
<SpoilerButton
tabIndex={0}
onClick={handleSpoilerClick}
hidden={isHidden}
/>
</p>
<div
tabIndex={!isHidden ? 0 : undefined}
className={classNames({
'whitespace-pre-wrap': withSpoiler,
'hidden': isHidden,
'block': !isHidden,
})}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>
{!isHidden && status.poll && typeof status.poll === 'string' && (
<Poll id={status.poll} status={status.url} />
)}
</div>
);
} else if (onClick) {
if (onClick) {
const output = [
<div
ref={node}

View File

@ -81,6 +81,14 @@ const SensitiveContentOverlay = (props: ISensitiveContentOverlay) => {
<Text theme='white' size='sm' weight='medium'>
{intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)}
</Text>
{status.spoiler_text && (
<div className='py-4 italic'>
<Text theme='white' size='md' weight='medium'>
&ldquo;<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />&rdquo;
</Text>
</div>
)}
</div>
<HStack alignItems='center' justifyContent='center' space={2}>

View File

@ -4,34 +4,22 @@ import { defineMessages, useIntl } from 'react-intl';
// import { connect } from 'react-redux';
import { useHistory } from 'react-router-dom';
// import { openModal } from 'soapbox/actions/modals';
// import { useAppDispatch } from 'soapbox/hooks';
import { CardHeader, CardTitle } from './ui';
const messages = defineMessages({
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
});
interface ISubNavigation {
message: String,
message: React.ReactNode,
/** @deprecated Unused. */
settings?: React.ComponentType,
}
const SubNavigation: React.FC<ISubNavigation> = ({ message }) => {
const intl = useIntl();
// const dispatch = useAppDispatch();
const history = useHistory();
// const ref = useRef(null);
// const [scrolled, setScrolled] = useState(false);
// const onOpenSettings = () => {
// dispatch(openModal('COMPONENT', { component: Settings }));
// };
const handleBackClick = () => {
if (window.history && window.history.length === 1) {
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 (
<CardHeader
aria-label={intl.formatMessage(messages.back)}

View File

@ -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 = 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;

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import Portal from '@reach/portal';
import { Portal } from '@reach/portal';
import { TooltipPopup, useTooltip } from '@reach/tooltip';
import React from 'react';

View File

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

View File

@ -1,26 +1,45 @@
import React, { useEffect } from 'react';
import { defineMessages, FormattedDate, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security';
import { Button, Card, CardBody, CardHeader, CardTitle, Column, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Token } from 'soapbox/reducers/security';
import type { Map as ImmutableMap } from 'immutable';
const messages = defineMessages({
header: { id: 'security.headers.tokens', defaultMessage: 'Sessions' },
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 {
token: Token,
isCurrent: boolean,
}
const AuthToken: React.FC<IAuthToken> = ({ token }) => {
const AuthToken: React.FC<IAuthToken> = ({ token, isCurrent }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
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));
}
};
return (
@ -42,7 +61,7 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
</Stack>
<div className='flex justify-end'>
<Button theme='primary' onClick={handleRevoke}>
<Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}>
{intl.formatMessage(messages.revoke)}
</Button>
</div>
@ -55,6 +74,11 @@ const AuthTokenList: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
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(() => {
dispatch(fetchOAuthTokens());
@ -63,7 +87,7 @@ const AuthTokenList: React.FC = () => {
const body = tokens ? (
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
{tokens.map((token) => (
<AuthToken key={token.id} token={token} />
<AuthToken key={token.id} token={token} isCurrent={token.id === currentTokenId} />
))}
</div>
) : <Spinner />;

View File

@ -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>
);
}
}

View File

@ -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);

View File

@ -10,8 +10,6 @@ import { useAppDispatch, useSettings } from 'soapbox/hooks';
import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' },
});
@ -44,7 +42,10 @@ const CommunityTimeline = () => {
return (
<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}>
<Timeline
scrollKey={`${timelineId}_timeline`}

View File

@ -10,7 +10,6 @@ import {
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
} from 'soapbox/actions/compose';
@ -38,6 +37,7 @@ import UploadButtonContainer from '../containers/upload_button_container';
import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter';
import SpoilerInput from './spoiler-input';
import TextCharacterCounter from './text_character_counter';
import VisualCharacterCounter from './visual_character_counter';
@ -49,7 +49,7 @@ const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
message: { id: 'compose_form.message', defaultMessage: 'Message' },
@ -134,7 +134,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
setComposeFocused(true);
};
const handleSubmit = () => {
const handleSubmit = (e?: React.FormEvent<Element>) => {
if (text !== autosuggestTextareaRef.current?.textarea?.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
@ -144,6 +144,10 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
// Submit disabled:
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)) {
return;
}
@ -167,10 +171,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
};
const handleChangeSpoilerText: React.ChangeEventHandler<HTMLInputElement> = (e) => {
dispatch(changeComposeSpoilerText(id, e.target.value));
};
const setCursor = (start: number, end: number = start) => {
if (!autosuggestTextareaRef.current?.textarea) return;
autosuggestTextareaRef.current.textarea.setSelectionRange(start, end);
@ -276,7 +276,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
}
return (
<Stack className='w-full' space={1} ref={formRef} onClick={handleClick}>
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
{scheduledStatusCount > 0 && !event && (
<Warning
message={(
@ -302,30 +302,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
{!shouldCondense && !event && <ReplyMentions composeId={id} />}
<div
className={classNames({
'relative transition-height': true,
'hidden': !spoiler,
})}
>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={spoilerText}
onChange={handleChangeSpoilerText}
onKeyDown={handleKeyDown}
disabled={!spoiler}
ref={spoilerTextRef}
suggestions={suggestions}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='border-none shadow-none px-0 py-2 text-base'
autoFocus
/>
</div>
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
placeholder={intl.formatMessage(textareaPlaceholder)}
@ -345,11 +321,19 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
>
{
!condensed &&
<div className='compose-form__modifiers'>
<Stack space={4} className='compose-form__modifiers'>
<UploadForm composeId={id} />
<PollForm composeId={id} />
<ScheduleFormContainer composeId={id} />
</div>
<SpoilerInput
composeId={id}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
ref={spoilerTextRef}
/>
</Stack>
}
</AutosuggestTextarea>
@ -370,7 +354,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
</div>
)}
<Button theme='primary' text={publishText} onClick={handleSubmit} disabled={disabledButton} />
<Button type='submit' theme='primary' text={publishText} disabled={disabledButton} />
</div>
</div>
</Stack>

View File

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

View File

@ -1,48 +0,0 @@
import React from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { changeComposeSensitivity } from 'soapbox/actions/compose';
import { FormGroup, Checkbox } from 'soapbox/components/ui';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
const messages = defineMessages({
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
});
interface ISensitiveButton {
composeId: string,
}
/** Button to mark own media as sensitive. */
const SensitiveButton: React.FC<ISensitiveButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(composeId);
const active = compose.sensitive === true;
const disabled = compose.spoiler === true;
const onClick = () => {
dispatch(changeComposeSensitivity(composeId));
};
return (
<div className='px-2.5 py-1'>
<FormGroup
labelText={<FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />}
labelTitle={intl.formatMessage(active ? messages.marked : messages.unmarked)}
>
<Checkbox
name='mark-sensitive'
checked={active}
onChange={onClick}
disabled={disabled}
/>
</FormGroup>
</div>
);
};
export default SensitiveButton;

View File

@ -0,0 +1,80 @@
import classNames from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeComposeSpoilerness, changeComposeSpoilerText } from 'soapbox/actions/compose';
import AutosuggestInput, { IAutosuggestInput } from 'soapbox/components/autosuggest_input';
import { Divider, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
const messages = defineMessages({
title: { id: 'compose_form.spoiler_title', defaultMessage: 'Sensitive content' },
placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
remove: { id: 'compose_form.spoiler_remove', defaultMessage: 'Remove sensitive' },
});
interface ISpoilerInput extends Pick<IAutosuggestInput, 'onSuggestionsFetchRequested' | 'onSuggestionsClearRequested' | 'onSuggestionSelected'> {
composeId: string extends 'default' ? never : string,
}
/** Text input for content warning in composer. */
const SpoilerInput = React.forwardRef<AutosuggestInput, ISpoilerInput>(({
composeId,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
onSuggestionSelected,
}, ref) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(composeId);
const handleChangeSpoilerText: React.ChangeEventHandler<HTMLInputElement> = (e) => {
dispatch(changeComposeSpoilerText(composeId, e.target.value));
};
const handleRemove = () => {
dispatch(changeComposeSpoilerness(composeId));
};
return (
<Stack
space={4}
className={classNames({
'relative transition-height': true,
'hidden': !compose.spoiler,
})}
>
<Divider />
<Stack space={2}>
<Text weight='medium'>
{intl.formatMessage(messages.title)}
</Text>
<AutosuggestInput
placeholder={intl.formatMessage(messages.placeholder)}
value={compose.spoiler_text}
onChange={handleChangeSpoilerText}
disabled={!compose.spoiler}
suggestions={compose.suggestions}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='rounded-md dark:!bg-transparent !bg-transparent'
ref={ref}
autoFocus
/>
<div className='text-center'>
<button type='button' className='text-danger-500' onClick={handleRemove}>
{intl.formatMessage(messages.remove)}
</button>
</div>
</Stack>
</Stack>
);
});
export default SpoilerInput;

View File

@ -3,7 +3,6 @@ import React from 'react';
import { useCompose } from 'soapbox/hooks';
import SensitiveButton from './sensitive-button';
import Upload from './upload';
import UploadProgress from './upload-progress';
@ -28,8 +27,6 @@ const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
<Upload id={id} key={id} composeId={composeId} />
))}
</div>
{!mediaIds.isEmpty() && <SensitiveButton composeId={composeId} />}
</div>
);
};

View File

@ -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;

View File

@ -89,6 +89,14 @@ const Developers: React.FC = () => {
</Text>
</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}>
<SvgIcon src={require('@tabler/icons/logout.svg')} className='text-gray-700 dark:text-gray-600' />

View File

@ -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;

View File

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

View File

@ -3,10 +3,10 @@ import { FormattedMessage } from 'react-intl';
import { connectHashtagStream } from 'soapbox/actions/streaming';
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 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';
@ -27,7 +27,6 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
const tags = params?.tags || { any: [], all: [], none: [] };
const dispatch = useAppDispatch();
const hasUnread = useAppSelector<boolean>(state => (state.timelines.getIn([`hashtag:${id}`, 'unread']) as number) > 0);
const disconnects = useRef<(() => void)[]>([]);
// Mastodon supports displaying results from multiple hashtags.
@ -100,7 +99,10 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
return (
<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
scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`}

View File

@ -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>
);
}
}

View File

@ -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);

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import { HotKeys } from 'react-hotkeys';
import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor } from 'react-intl';
import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor, defineMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { mentionCompose } from 'soapbox/actions/compose';
@ -58,6 +58,11 @@ const icons: Record<NotificationType, string> = {
'pleroma:participation_accepted': require('@tabler/icons/calendar-event.svg'),
};
const nameMessage = defineMessage({
id: 'notification.name',
defaultMessage: '{link}{others}',
});
const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
follow: {
id: 'notification.follow',
@ -130,10 +135,7 @@ const buildMessage = (
instanceTitle: string,
): React.ReactNode => {
const link = buildLink(account);
const name = intl.formatMessage({
id: 'notification.name',
defaultMessage: '{link}{others}',
}, {
const name = intl.formatMessage(nameMessage, {
link,
others: totalCount && totalCount > 0 ? (
<FormattedMessage
@ -283,7 +285,7 @@ const Notification: React.FC<INotificaton> = (props) => {
};
const renderContent = () => {
switch (type) {
switch (type as NotificationType) {
case 'follow':
case 'user_approved':
return account && typeof account === 'object' ? (

View File

@ -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));

View File

@ -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);

View File

@ -77,9 +77,9 @@ const languages = {
const messages = defineMessages({
heading: { id: 'column.preferences', defaultMessage: 'Preferences' },
display_media_default: { id: 'preferences.fields.display_media.default', defaultMessage: 'Hide media marked as sensitive' },
display_media_hide_all: { id: 'preferences.fields.display_media.hide_all', defaultMessage: 'Always hide media' },
display_media_show_all: { id: 'preferences.fields.display_media.show_all', defaultMessage: 'Always show media' },
displayPostsDefault: { id: 'preferences.fields.display_media.default', defaultMessage: 'Hide posts marked as sensitive' },
displayPostsHideAll: { id: 'preferences.fields.display_media.hide_all', defaultMessage: 'Always hide posts' },
displayPostsShowAll: { id: 'preferences.fields.display_media.show_all', defaultMessage: 'Always show posts' },
privacy_public: { id: 'preferences.options.privacy_public', defaultMessage: 'Public' },
privacy_unlisted: { id: 'preferences.options.privacy_unlisted', defaultMessage: 'Unlisted' },
privacy_followers_only: { id: 'preferences.options.privacy_followers_only', defaultMessage: 'Followers-only' },
@ -102,9 +102,9 @@ const Preferences = () => {
};
const displayMediaOptions = React.useMemo(() => ({
default: intl.formatMessage(messages.display_media_default),
hide_all: intl.formatMessage(messages.display_media_hide_all),
show_all: intl.formatMessage(messages.display_media_show_all),
default: intl.formatMessage(messages.displayPostsDefault),
hide_all: intl.formatMessage(messages.displayPostsHideAll),
show_all: intl.formatMessage(messages.displayPostsShowAll),
}), []);
const defaultPrivacyOptions = React.useMemo(() => ({
@ -149,7 +149,7 @@ const Preferences = () => {
/>
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.media_display_label' defaultMessage='Media display' />}>
<ListItem label={<FormattedMessage id='preferences.fields.media_display_label' defaultMessage='Sensitive content' />}>
<SelectDropdown
items={displayMediaOptions}
defaultValue={settings.get('displayMedia') as string | undefined}

View File

@ -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>
);
}
}

View File

@ -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);

View File

@ -14,8 +14,6 @@ import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker';
import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Fediverse timeline' },
dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' },
@ -65,8 +63,12 @@ const CommunityTimeline = () => {
return (
<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 />
{showExplanationBox && <div className='mb-4'>
<Accordion
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}

View File

@ -44,7 +44,6 @@ const ScheduledStatus: React.FC<IScheduledStatus> = ({ statusId, ...other }) =>
<StatusContent
status={status}
expanded
collapsable
/>

View File

@ -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);

View File

@ -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 React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
@ -8,153 +5,12 @@ import Overlay from 'react-overlays/lib/Overlay';
import Icon from 'soapbox/components/icon';
import IconPickerMenu from './icon-picker-menu';
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'];
@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 {
static propTypes = {
@ -243,3 +99,5 @@ class IconPickerDropdown extends React.PureComponent {
}
}
export default injectIntl(IconPickerDropdown);

View File

@ -7,6 +7,7 @@ import StatusMedia from 'soapbox/components/status-media';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import StatusContent from 'soapbox/components/status_content';
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 AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
@ -29,7 +30,6 @@ interface IDetailedStatus {
const DetailedStatus: React.FC<IDetailedStatus> = ({
status,
onToggleHidden,
onOpenCompareHistoryModal,
onToggleMediaVisibility,
showMedia,
@ -37,10 +37,6 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const handleExpandedToggle = () => {
onToggleHidden(status);
};
const handleOpenCompareHistoryModal = () => {
onOpenCompareHistoryModal(status);
};
@ -51,7 +47,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
if (!account || typeof account !== 'object') return null;
const isUnderReview = actualStatus.visibility === 'self';
const isSensitive = actualStatus.sensitive;
const isSensitive = actualStatus.hidden;
let statusTypeIcon = null;
@ -97,20 +93,21 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
})
}
>
{(isUnderReview || isSensitive) ? (
{(isUnderReview || isSensitive) && (
<SensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
) : null}
)}
<StatusContent
status={actualStatus}
expanded={!actualStatus.hidden}
onExpandedToggle={handleExpandedToggle}
/>
<Stack space={4}>
<StatusContent status={actualStatus} translatable />
<TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}>
<StatusMedia
status={actualStatus}
showMedia={showMedia}
@ -119,6 +116,9 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
{quote}
</Stack>
)}
</Stack>
</Stack>
<HStack justifyContent='between' alignItems='center' className='py-2' wrap>
<StatusInteractionBar status={actualStatus} />

View File

@ -134,6 +134,7 @@ const Thread: React.FC<IThread> = (props) => {
const me = useAppSelector(state => state.me);
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
const displayMedia = settings.get('displayMedia') as DisplayMedia;
const isUnderReview = status?.visibility === 'self';
const { ancestorsIds, descendantsIds } = useAppSelector(state => {
let ancestorsIds = ImmutableOrderedSet<string>();
@ -481,6 +482,8 @@ const Thread: React.FC<IThread> = (props) => {
onOpenCompareHistoryModal={handleOpenCompareHistoryModal}
/>
{!isUnderReview ? (
<>
<hr className='mb-2 border-t-2 dark:border-primary-800' />
<StatusActionBar
@ -489,6 +492,8 @@ const Thread: React.FC<IThread> = (props) => {
space='expand'
withLabels
/>
</>
) : null}
</div>
</HotKeys>

View File

@ -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>
);
}
}

View File

@ -37,7 +37,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
{...compProps}
rel='noopener'
data-index={i}
className={classNames({ active, destructive })}
className={classNames('w-full', { active, destructive })}
data-method={isLogout ? 'delete' : null}
>
{icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />}

View File

@ -202,6 +202,7 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
link={link}
alt={attachment.description}
key={attachment.url}
visible
/>
);
} else if (attachment.type === 'audio') {

View File

@ -56,7 +56,6 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => {
<StatusContent
status={status}
expanded
collapsable
/>

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import { verifyCredentials } from 'soapbox/actions/auth';
@ -10,6 +10,37 @@ import { FormGroup, PhoneInput, Modal, Stack, Text } from 'soapbox/components/ui
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { getAccessToken } from 'soapbox/utils/auth';
const messages = defineMessages({
verificationInvalid: {
id: 'sms_verification.invalid',
defaultMessage: 'Please enter a valid phone number.',
},
verificationSuccess: {
id: 'sms_verification.success',
defaultMessage: 'A verification code has been sent to your phone number.',
},
verificationFail: {
id: 'sms_verification.fail',
defaultMessage: 'Failed to send SMS message to your phone number.',
},
verificationExpired: {
id: 'sms_verification.expired',
defaultMessage: 'Your SMS token has expired.',
},
verifySms: {
id: 'sms_verification.modal.verify_sms',
defaultMessage: 'Verify SMS',
},
verifyNumber: {
id: 'sms_verification.modal.verify_number',
defaultMessage: 'Verify phone number',
},
verifyCode: {
id: 'sms_verification.modal.verify_code',
defaultMessage: 'Verify code',
},
});
interface IVerifySmsModal {
onClose: (type: string) => void,
}
@ -47,10 +78,7 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
setStatus(Statuses.IDLE);
dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Please enter a valid phone number.',
}),
intl.formatMessage(messages.verificationInvalid),
),
);
return;
@ -59,10 +87,7 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
dispatch(reRequestPhoneVerification(phone!)).then(() => {
dispatch(
snackbar.success(
intl.formatMessage({
id: 'sms_verification.success',
defaultMessage: 'A verification code has been sent to your phone number.',
}),
intl.formatMessage(messages.verificationSuccess),
),
);
})
@ -70,10 +95,7 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
.catch(() => {
dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.fail',
defaultMessage: 'Failed to send SMS message to your phone number.',
}),
intl.formatMessage(messages.verificationFail),
),
);
});
@ -102,20 +124,11 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
const confirmationText = useMemo(() => {
switch (status) {
case Statuses.IDLE:
return intl.formatMessage({
id: 'sms_verification.modal.verify_sms',
defaultMessage: 'Verify SMS',
});
return intl.formatMessage(messages.verifySms);
case Statuses.READY:
return intl.formatMessage({
id: 'sms_verification.modal.verify_number',
defaultMessage: 'Verify phone number',
});
return intl.formatMessage(messages.verifyNumber);
case Statuses.REQUESTED:
return intl.formatMessage({
id: 'sms_verification.modal.verify_code',
defaultMessage: 'Verify code',
});
return intl.formatMessage(messages.verifyCode);
default:
return null;
}
@ -126,12 +139,13 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
case Statuses.IDLE:
return (
<Text theme='muted'>
{intl.formatMessage({
id: 'sms_verification.modal.verify_help_text',
defaultMessage: 'Verify your phone number to start using {instance}.',
}, {
<FormattedMessage
id='sms_verification.modal.verify_help_text'
defaultMessage='Verify your phone number to start using {instance}.'
values={{
instance: title,
})}
}}
/>
</Text>
);
case Statuses.READY:
@ -149,10 +163,10 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
return (
<>
<Text theme='muted' size='sm' align='center'>
{intl.formatMessage({
id: 'sms_verification.modal.enter_code',
defaultMessage: 'We sent you a 6-digit code via SMS. Enter it below.',
})}
<FormattedMessage
id='sms_verification.modal.enter_code'
defaultMessage='We sent you a 6-digit code via SMS. Enter it below.'
/>
</Text>
<OtpInput
@ -184,10 +198,7 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
})
.catch(() => dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Your SMS token has expired.',
}),
intl.formatMessage(messages.verificationExpired),
),
));
};
@ -201,10 +212,10 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
return (
<Modal
title={
intl.formatMessage({
id: 'sms_verification.modal.verify_title',
defaultMessage: 'Verify your phone number',
})
<FormattedMessage
id='sms_verification.modal.verify_title'
defaultMessage='Verify your phone number'
/>
}
onClose={() => onClose('VERIFY_SMS')}
cancelAction={status === Statuses.IDLE ? () => onClose('VERIFY_SMS') : undefined}
@ -212,10 +223,12 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
confirmationAction={onConfirmationClick}
confirmationText={confirmationText}
secondaryAction={status === Statuses.REQUESTED ? resendVerificationCode : undefined}
secondaryText={status === Statuses.REQUESTED ? intl.formatMessage({
id: 'sms_verification.modal.resend_code',
defaultMessage: 'Resend verification code?',
}) : undefined}
secondaryText={status === Statuses.REQUESTED ? (
<FormattedMessage
id='sms_verification.modal.resend_code'
defaultMessage='Resend verification code?'
/>
) : undefined}
secondaryDisabled={requestedAnother}
>
<Stack space={4}>

View File

@ -4,9 +4,10 @@ import Toggle from 'react-toggle';
import { muteAccount } from 'soapbox/actions/accounts';
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 { 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';
const getAccount = makeGetAccount();
@ -16,12 +17,14 @@ const MuteModal = () => {
const account = useAppSelector((state) => getAccount(state, state.mutes.new.accountId!));
const notifications = useAppSelector((state) => state.mutes.new.notifications);
const duration = useAppSelector((state) => state.mutes.new.duration);
const mutesDuration = useFeatures().mutesDuration;
if (!account) return null;
const handleClick = () => {
dispatch(closeModal());
dispatch(muteAccount(account.id, notifications));
dispatch(muteAccount(account.id, notifications, duration));
};
const handleCancel = () => {
@ -32,6 +35,12 @@ const MuteModal = () => {
dispatch(toggleHideNotifications());
};
const handleChangeMuteDuration = (expiresIn: number): void => {
dispatch(changeMuteDuration(expiresIn));
};
const toggleAutoExpire = () => handleChangeMuteDuration(duration ? 0 : 2 * 60 * 60 * 24);
return (
<Modal
title={
@ -69,6 +78,32 @@ const MuteModal = () => {
/>
</HStack>
</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>
</Modal>
);

View File

@ -79,7 +79,6 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
<StatusContent
status={status}
expanded
collapsable
/>

View File

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

View File

@ -115,6 +115,7 @@ import {
AuthTokenList,
EventInformation,
EventDiscussion,
ServiceWorkerInfo,
} from './util/async-components';
import { WrappedRoute } from './util/react_router_helpers';
@ -316,6 +317,7 @@ const SwitchingColumnsArea: React.FC = ({ 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/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='/error/network' developerOnly page={EmptyPage} component={() => new Promise((_resolve, reject) => reject())} content={children} />
<WrappedRoute path='/error' developerOnly page={EmptyPage} component={IntentionalError} content={children} />

View File

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

View File

@ -28,6 +28,11 @@ const messages = defineMessages({
tokenNotFoundBody: { id: 'email_passthru.token_not_found.body', defaultMessage: 'Your email token was not found. Please request a new email confirmation from the {bold} from which you sent this email confirmation.' },
tokenExpiredHeading: { id: 'email_passthru.token_expired.heading', defaultMessage: 'Token Expired' },
tokenExpiredBody: { id: 'email_passthru.token_expired.body', defaultMessage: 'Your email token has expired. Please request a new email confirmation from the {bold} from which you sent this email confirmation.' },
emailConfirmed: { id: 'email_passthru.success', defaultMessage: 'Your email has been verified!' },
genericFail: { id: 'email_passthru.fail.generic', defaultMessage: 'Unable to confirm your email' },
tokenExpired: { id: 'email_passthru.fail.expired', defaultMessage: 'Your email token has expired' },
tokenNotFound: { id: 'email_passthru.fail.not_found', defaultMessage: 'Your email token is invalid.' },
invalidToken: { id: 'email_passthru.fail.invalid_token', defaultMessage: 'Your token is invalid' },
});
const Success = () => {
@ -116,30 +121,21 @@ const EmailPassThru = () => {
dispatch(confirmEmailVerification(token))
.then(() => {
setStatus(Statuses.SUCCESS);
dispatch(snackbar.success(intl.formatMessage({ id: 'email_passthru.success', defaultMessage: 'Your email has been verified!' })));
dispatch(snackbar.success(intl.formatMessage(messages.emailConfirmed)));
})
.catch((error: AxiosError<any>) => {
const errorKey = error?.response?.data?.error;
let message = intl.formatMessage({
id: 'email_passthru.fail.generic',
defaultMessage: 'Unable to confirm your email',
});
let message = intl.formatMessage(messages.genericFail);
if (errorKey) {
switch (errorKey) {
case 'token_expired':
message = intl.formatMessage({
id: 'email_passthru.fail.expired',
defaultMessage: 'Your email token has expired.',
});
message = intl.formatMessage(messages.tokenExpired);
setStatus(Statuses.TOKEN_EXPIRED);
break;
case 'token_not_found':
message = intl.formatMessage({
id: 'email_passthru.fail.not_found',
defaultMessage: 'Your email token is invalid.',
});
message = 'Your token is invalid';
message = intl.formatMessage(messages.tokenNotFound);
message = intl.formatMessage(messages.invalidToken);
setStatus(Statuses.TOKEN_NOT_FOUND);
break;
default:

View File

@ -1,6 +1,6 @@
import { AxiosError } from 'axios';
import React from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import { checkEmailVerification, postEmailVerification, requestEmailVerification } from 'soapbox/actions/verification';
@ -8,6 +8,13 @@ import Icon from 'soapbox/components/icon';
import { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
verificationSuccess: { id: 'email_verification.success', defaultMessage: 'Verification email sent successfully.' },
verificationFail: { id: 'email_verification.fail', defaultMessage: 'Failed to request email verification.' },
verificationFailTakenAlert: { id: 'emai_verifilcation.exists', defaultMessage: 'This email has already been taken.' },
verificationFailTaken: { id: 'email_verification.taken', defaultMessage: 'is taken' },
});
const Statuses = {
IDLE: 'IDLE',
REQUESTED: 'REQUESTED',
@ -77,26 +84,23 @@ const EmailVerification = () => {
dispatch(
snackbar.success(
intl.formatMessage({
id: 'email_verification.exists',
defaultMessage: 'Verification email sent successfully.',
}),
intl.formatMessage(messages.verificationSuccess),
),
);
})
.catch((error: AxiosError) => {
const errorMessage = (error.response?.data as any)?.error;
const isEmailTaken = errorMessage === 'email_taken';
let message = intl.formatMessage({ id: 'email_verification.fail', defaultMessage: 'Failed to request email verification.' });
let message = intl.formatMessage(messages.verificationFail);
if (isEmailTaken) {
message = intl.formatMessage({ id: 'email_verification.exists', defaultMessage: 'This email has already been taken.' });
message = intl.formatMessage(messages.verificationFailTakenAlert);
} else if (errorMessage) {
message = errorMessage;
}
if (isEmailTaken) {
setErrors([intl.formatMessage({ id: 'email_verification.taken', defaultMessage: 'is taken' })]);
setErrors([intl.formatMessage(messages.verificationFailTaken)]);
}
dispatch(snackbar.error(message));
@ -111,7 +115,9 @@ const EmailVerification = () => {
return (
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 dark:border-gray-800 border-solid -mx-4 sm:-mx-10'>
<h1 className='text-center font-bold text-2xl'>{intl.formatMessage({ id: 'email_verification.header', defaultMessage: 'Enter your email address' })}</h1>
<h1 className='text-center font-bold text-2xl'>
<FormattedMessage id='email_verification.header' defaultMessage='Enter your email address' />
</h1>
</div>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>

View File

@ -1,6 +1,6 @@
import { AxiosError } from 'axios';
import React from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import snackbar from 'soapbox/actions/snackbar';
@ -8,6 +8,13 @@ import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/acti
import { Button, Form, FormGroup, PhoneInput, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
verificationInvalid: { id: 'sms_verification.invalid', defaultMessage: 'Please enter a valid phone number.' },
verificationSuccess: { id: 'sms_verification.success', defaultMessage: 'A verification code has been sent to your phone number.' },
verificationFail: { id: 'sms_verification.fail', defaultMessage: 'Failed to send SMS message to your phone number.' },
verificationExpired: { id: 'sms_verification.expired', defaultMessage: 'Your SMS token has expired.' },
});
const Statuses = {
IDLE: 'IDLE',
REQUESTED: 'REQUESTED',
@ -38,10 +45,7 @@ const SmsVerification = () => {
setStatus(Statuses.IDLE);
dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Please enter a valid phone number.',
}),
intl.formatMessage(messages.verificationInvalid),
),
);
return;
@ -50,18 +54,12 @@ const SmsVerification = () => {
dispatch(requestPhoneVerification(phone!)).then(() => {
dispatch(
snackbar.success(
intl.formatMessage({
id: 'sms_verification.success',
defaultMessage: 'A verification code has been sent to your phone number.',
}),
intl.formatMessage(messages.verificationSuccess),
),
);
setStatus(Statuses.REQUESTED);
}).catch((error: AxiosError) => {
const message = (error.response?.data as any)?.message || intl.formatMessage({
id: 'sms_verification.fail',
defaultMessage: 'Failed to send SMS message to your phone number.',
});
const message = (error.response?.data as any)?.message || intl.formatMessage(messages.verificationFail);
dispatch(snackbar.error(message));
setStatus(Statuses.FAIL);
@ -78,10 +76,7 @@ const SmsVerification = () => {
dispatch(confirmPhoneVerification(verificationCode))
.catch(() => dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Your SMS token has expired.',
}),
intl.formatMessage(messages.verificationExpired),
),
));
};
@ -97,7 +92,7 @@ const SmsVerification = () => {
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 dark:border-gray-800 border-solid -mx-4 sm:-mx-10'>
<h1 className='text-center font-bold text-2xl'>
{intl.formatMessage({ id: 'sms_verification.sent.header', defaultMessage: 'Verification code' })}
<FormattedMessage id='sms_verification.sent.header' defaultMessage='Verification code' />
</h1>
</div>
@ -136,7 +131,9 @@ const SmsVerification = () => {
return (
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 dark:border-gray-800 border-solid -mx-4 sm:-mx-10'>
<h1 className='text-center font-bold text-2xl'>{intl.formatMessage({ id: 'sms_verification.header', defaultMessage: 'Enter your phone number' })}</h1>
<h1 className='text-center font-bold text-2xl'>
<FormattedMessage id='sms_verification.header' defaultMessage='Enter your phone number' />
</h1>
</div>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>

File diff suppressed because it is too large Load Diff

View File

@ -288,7 +288,7 @@
"compose_form.sensitive.unmarked": "Media is not marked as sensitive",
"compose_form.spoiler.marked": "Text is hidden behind warning",
"compose_form.spoiler.unmarked": "Text is not hidden",
"compose_form.spoiler_placeholder": "Write your warning here",
"compose_form.spoiler_placeholder": "Write your warning here (optional)",
"confirmation_modal.cancel": "Cancel",
"confirmations.admin.deactivate_user.confirm": "Deactivate @{name}",
"confirmations.admin.deactivate_user.heading": "Deactivate @{acct}",
@ -794,12 +794,12 @@
"preferences.fields.autoload_timelines_label": "Automatically load new posts when scrolled to the top of the page",
"preferences.fields.boost_modal_label": "Show confirmation dialog before reposting",
"preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post",
"preferences.fields.display_media.default": "Hide media marked as sensitive",
"preferences.fields.display_media.hide_all": "Always hide media",
"preferences.fields.display_media.show_all": "Always show media",
"preferences.fields.display_media.default": "Hide posts marked as sensitive",
"preferences.fields.display_media.hide_all": "Always hide posts",
"preferences.fields.display_media.show_all": "Always show posts",
"preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings",
"preferences.fields.language_label": "Display Language",
"preferences.fields.media_display_label": "Media display",
"preferences.fields.media_display_label": "Sensitive content",
"preferences.hints.feed": "In your home feed",
"privacy.change": "Adjust post privacy",
"privacy.direct.long": "Post to mentioned users only",

View File

@ -915,7 +915,7 @@
"preferences.fields.expand_spoilers_label": "Zawsze rozwijaj wpisy z ostrzeżeniami o zawartości",
"preferences.fields.language_label": "Język",
"preferences.fields.media_display_label": "Wyświetlanie zawartości multimedialnej",
"preferences.fields.missing_description_modal_label": "SPokazuj prośbę o potwierdzenie przed wysłaniem wpisu bez opisu multimediów",
"preferences.fields.missing_description_modal_label": "Pokazuj prośbę o potwierdzenie przed wysłaniem wpisu bez opisu multimediów",
"preferences.fields.privacy_label": "Prywatność wpisów",
"preferences.fields.reduce_motion_label": "Ogranicz ruch w animacjach",
"preferences.fields.system_font_label": "Używaj domyślnej czcionki systemu",
@ -1211,8 +1211,11 @@
"status.show_less_all": "Zwiń wszystkie",
"status.show_more": "Rozwiń",
"status.show_more_all": "Rozwiń wszystkie",
"status.show_original": "Pokaż oryginalny wpis",
"status.title": "Wpis",
"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.unbookmarked": "Usunięto z zakładek.",
"status.unmute_conversation": "Cofnij wyciszenie konwersacji",
@ -1255,6 +1258,7 @@
"time_remaining.seconds": "{number, plural, one {Pozostała # sekunda} few {Pozostały # sekundy} many {Pozostało # sekund} other {Pozostało # sekund}}",
"trends.count_by_accounts": "{count} {rawCount, plural, one {osoba rozmawia} few {osoby rozmawiają} other {osób rozmawia}} o tym",
"trends.title": "Trendy",
"trendsPanel.viewAll": "Pokaż wszystkie",
"ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Soapbox.",
"unauthorized_modal.footer": "Masz już konto? {login}.",
"unauthorized_modal.text": "Musisz się zalogować, aby to zrobić.",

View File

@ -0,0 +1,2 @@
[
]

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

View File

@ -192,4 +192,12 @@ describe('normalizeInstance()', () => {
const result = normalizeInstance(instance);
expect(result.title).toBe('pixelfed');
});
it('renames Akkoma to Pleroma', () => {
const instance = require('soapbox/__fixtures__/akkoma-instance.json');
const result = normalizeInstance(instance);
expect(result.version).toEqual('2.7.2 (compatible; Pleroma 2.4.5+akkoma)');
});
});

View File

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

View File

@ -98,6 +98,17 @@ const normalizeVersion = (instance: ImmutableMap<string, any>) => {
});
};
/** Rename Akkoma to Pleroma+akkoma */
const fixAkkoma = (instance: ImmutableMap<string, any>) => {
const version: string = instance.get('version', '');
if (version.includes('Akkoma')) {
return instance.set('version', '2.7.2 (compatible; Pleroma 2.4.5+akkoma)');
} else {
return instance;
}
};
// Normalize instance (Pleroma, Mastodon, etc.) to Mastodon's format
export const normalizeInstance = (instance: Record<string, any>) => {
return InstanceRecord(
@ -117,6 +128,7 @@ export const normalizeInstance = (instance: Record<string, any>) => {
// Normalize version
normalizeVersion(instance);
fixAkkoma(instance);
// Merge defaults
instance.mergeDeepWith(mergeDefined, InstanceRecord());

View File

@ -77,6 +77,7 @@ export const StatusRecord = ImmutableRecord({
hidden: false,
search_index: '',
spoilerHtml: '',
translation: null as ImmutableMap<string, string> | null,
});
const normalizeAttachments = (status: ImmutableMap<string, any>) => {
@ -163,6 +164,13 @@ const fixFiltered = (status: ImmutableMap<string, any>) => {
status.delete('filtered');
};
/** If the status contains spoiler text, treat it as sensitive. */
const fixSensitivity = (status: ImmutableMap<string, any>) => {
if (status.get('spoiler_text')) {
status.set('sensitive', true);
}
};
// Normalize event
const normalizeEvent = (status: ImmutableMap<string, any>) => {
if (status.getIn(['pleroma', 'event'])) {
@ -184,6 +192,7 @@ export const normalizeStatus = (status: Record<string, any>) => {
addSelfMention(status);
fixQuote(status);
fixFiltered(status);
fixSensitivity(status);
normalizeEvent(status);
}),
);

View File

@ -3,10 +3,10 @@
* @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'));
/** 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'));
/** Whether initial data was loaded into the page by server-side-rendering (SSR). */

View File

@ -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,
]),
};

View File

@ -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, {}> {}
}

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -187,30 +187,8 @@ describe('compose reducer', () => {
});
});
it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, don\'t toggle if spoiler active', () => {
const state = initialState.set('home', ReducerCompose({ spoiler: true, sensitive: true, idempotencyKey: '' }));
const action = {
type: actions.COMPOSE_SENSITIVITY_CHANGE,
id: 'home',
};
expect(reducer(state, action).toJS().home).toMatchObject({
sensitive: true,
});
});
it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, toggle if spoiler inactive', () => {
const state = initialState.set('home', ReducerCompose({ spoiler: false, sensitive: true }));
const action = {
type: actions.COMPOSE_SENSITIVITY_CHANGE,
id: 'home',
};
expect(reducer(state, action).toJS().home).toMatchObject({
sensitive: false,
});
});
it('should handle COMPOSE_SPOILERNESS_CHANGE on CW button click', () => {
const state = initialState.set('home', ReducerCompose({ spoiler_text: 'spoiler text', spoiler: true, media_attachments: ImmutableList() }));
const state = initialState.set('home', ReducerCompose({ spoiler_text: 'spoiler text', spoiler: true, sensitive: true, media_attachments: ImmutableList() }));
const action = {
type: actions.COMPOSE_SPOILERNESS_CHANGE,
id: 'home',
@ -218,6 +196,7 @@ describe('compose reducer', () => {
expect(reducer(state, action).toJS().home).toMatchObject({
spoiler: false,
spoiler_text: '',
sensitive: false,
});
});

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