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" /> <meta name="referrer" content="same-origin" />
<link href="/manifest.json" rel="manifest"> <link href="/manifest.json" rel="manifest">
<!--server-generated-meta--> <!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
<%= snippets %> <%= snippets %>
</head> </head>
<body class="theme-mode-light no-reduce-motion"> <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 { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
@ -359,14 +359,30 @@ const unblockAccountFail = (error: AxiosError) => ({
error, error,
}); });
const muteAccount = (id: string, notifications?: boolean) => const muteAccount = (id: string, notifications?: boolean, duration = 0) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null; if (!isLoggedIn(getState)) return null;
dispatch(muteAccountRequest(id)); dispatch(muteAccountRequest(id));
const params: Record<string, any> = {
notifications,
};
if (duration) {
const state = getState();
const instance = state.instance;
const v = parseVersion(instance.version);
if (v.software === PLEROMA) {
params.expires_in = duration;
} else {
params.duration = duration;
}
}
return api(getState) return api(getState)
.post(`/api/v1/accounts/${id}/mute`, { notifications }) .post(`/api/v1/accounts/${id}/mute`, params)
.then(response => { .then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
return dispatch(muteAccountSuccess(response.data, getState().statuses)); return dispatch(muteAccountSuccess(response.data, getState().statuses));

View File

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

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_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE'; const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE';
const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_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)); dispatch(updateTagHistory(composeId, newHistory));
}; };
const changeComposeSensitivity = (composeId: string) => ({
type: COMPOSE_SENSITIVITY_CHANGE,
id: composeId,
});
const changeComposeSpoilerness = (composeId: string) => ({ const changeComposeSpoilerness = (composeId: string) => ({
type: COMPOSE_SPOILERNESS_CHANGE, type: COMPOSE_SPOILERNESS_CHANGE,
id: composeId, id: composeId,
@ -758,7 +752,6 @@ export {
COMPOSE_SUGGESTION_SELECT, COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_TAGS_UPDATE, COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE, COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_TYPE_CHANGE, COMPOSE_TYPE_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE,
@ -813,7 +806,6 @@ export {
selectComposeSuggestion, selectComposeSuggestion,
updateSuggestionTags, updateSuggestionTags,
updateTagHistory, updateTagHistory,
changeComposeSensitivity,
changeComposeSpoilerness, changeComposeSpoilerness,
changeComposeContentType, changeComposeContentType,
changeComposeSpoilerText, 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 }>( export const fetchNodeinfo = createAsyncThunk<void, void, { state: RootState }>(
'nodeinfo/fetch', 'nodeinfo/fetch',
async(_arg, { getState }) => { async(_arg, { getState }) => await api(getState).get('/nodeinfo/2.1.json'),
return await api(getState).get('/nodeinfo/2.1.json');
},
); );

View File

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

View File

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

View File

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

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) => { const handleClick: React.MouseEventHandler = (e) => {
e.stopPropagation(); 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 ( return (
@ -221,7 +227,7 @@ const Account = ({
<Text tag='span' theme='muted' size='sm'>&middot;</Text> <Text tag='span' theme='muted' size='sm'>&middot;</Text>
{timestampUrl ? ( {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} /> <RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
</Link> </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')} /> <Icon className='h-5 w-5 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/pencil.svg')} />
</> </>
) : null} ) : null}
{actionType === 'muting' && account.mute_expires_at ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
<Text theme='muted' size='sm'><RelativeTimestamp timestamp={account.mute_expires_at} futureDate /></Text>
</>
) : null}
</HStack> </HStack>
{note ? ( {note ? (

View File

@ -1,4 +1,4 @@
import Portal from '@reach/portal'; import { Portal } from '@reach/portal';
import classNames from 'clsx'; import classNames from 'clsx';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React from 'react'; 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, value: string,
suggestions: ImmutableList<any>, suggestions: ImmutableList<any>,
disabled?: boolean, disabled?: boolean,

View File

@ -1,4 +1,4 @@
import Portal from '@reach/portal'; import { Portal } from '@reach/portal';
import classNames from 'clsx'; import classNames from 'clsx';
import React from 'react'; import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component'; 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' }, // hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
// }); // });
export default @withRouter
class ColumnHeader extends React.PureComponent { class ColumnHeader extends React.PureComponent {
static propTypes = { static propTypes = {
@ -126,3 +125,5 @@ class ColumnHeader extends React.PureComponent {
// } // }
} }
export default withRouter(ColumnHeader);

View File

@ -143,12 +143,14 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
this.props.onClose(); this.props.onClose();
if (typeof action === 'function') { e.stopPropagation();
e.preventDefault();
action(e); if (to) {
} else if (to) {
e.preventDefault(); e.preventDefault();
this.props.history.push(to); 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} data-index={i}
target={newTab ? '_blank' : undefined} target={newTab ? '_blank' : undefined}
data-method={isLogout ? 'delete' : undefined} data-method={isLogout ? 'delete' : undefined}
title={text}
> >
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />} {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 { captureException } from 'soapbox/monitoring';
import KVStore from 'soapbox/storage/kv_store'; import KVStore from 'soapbox/storage/kv_store';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
import { unregisterSw } from 'soapbox/utils/sw';
import SiteLogo from './site-logo'; import SiteLogo from './site-logo';
@ -15,16 +16,6 @@ import type { RootState } from 'soapbox/store';
const goHome = () => location.href = '/'; const goHome = () => location.href = '/';
/** Unregister the ServiceWorker */
// https://stackoverflow.com/a/49771828/8811886
const unregisterSw = async(): Promise<void> => {
if (navigator.serviceWorker) {
const registrations = await navigator.serviceWorker.getRegistrations();
const unregisterAll = registrations.map(r => r.unregister());
await Promise.all(unregisterAll);
}
};
const mapStateToProps = (state: RootState) => { const mapStateToProps = (state: RootState) => {
const { links, logo } = getSoapboxConfig(state); const { links, logo } = getSoapboxConfig(state);

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

View File

@ -1,16 +1,19 @@
import classNames from 'clsx'; import classNames from 'clsx';
import React, { useState } from 'react'; import React, { MouseEventHandler, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage, FormattedList } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import StatusMedia from 'soapbox/components/status-media'; 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 AccountContainer from 'soapbox/containers/account_container';
import { useSettings } from 'soapbox/hooks'; import { useSettings } from 'soapbox/hooks';
import { defaultMediaVisibility } from 'soapbox/utils/status'; import { defaultMediaVisibility } from 'soapbox/utils/status';
import EventPreview from './event-preview'; import EventPreview from './event-preview';
import OutlineBox from './outline-box'; 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'; 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 [showMedia, setShowMedia] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
const handleExpandClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleExpandClick: MouseEventHandler<HTMLDivElement> = (e) => {
if (!status) return; if (!status) return;
const account = status.account as AccountEntity; const account = status.account as AccountEntity;
if (!compose && e.button === 0) { 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.stopPropagation();
e.preventDefault(); e.preventDefault();
} }
@ -58,57 +66,6 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
setShowMedia(!showMedia); 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) { if (!status) {
return null; return null;
} }
@ -128,7 +85,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
return ( return (
<OutlineBox <OutlineBox
data-testid='quoted-status' data-testid='quoted-status'
className={classNames('mt-3 cursor-pointer', { className={classNames('cursor-pointer', {
'hover:bg-gray-100 dark:hover:bg-gray-800': !compose, 'hover:bg-gray-100 dark:hover:bg-gray-800': !compose,
})} })}
> >
@ -145,23 +102,37 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
withLinkToProfile={!compose} withLinkToProfile={!compose}
/> />
{renderReplyMentions()} <StatusReplyMentions status={status} hoverable={false} />
{status.event ? <EventPreview status={status} hideAction /> : ( {status.event ? <EventPreview status={status} hideAction /> : (
<> <Stack className={classNames('relative', {
<Text 'min-h-[220px]': status.hidden,
className='break-words status__content status__content--quote' })}
size='sm' >
dangerouslySetInnerHTML={{ __html: status.contentHtml }} {(status.hidden) && (
/> <SensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
)}
<StatusMedia <Stack space={4}>
status={status} <StatusContent
muted={compose} status={status}
showMedia={showMedia} collapsable
onToggleVisibility={handleToggleMediaVisibility} />
/>
</> {(status.card || status.media_attachments.size > 0) && (
<StatusMedia
status={status}
muted={compose}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
)}
</Stack>
</Stack>
)} )}
</Stack> </Stack>
</OutlineBox> </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} ref={ref}
onClick={handleClick} onClick={handleClick}
className={classNames({ 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, '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-primary-600': isActive, 'dark:text-gray-100 text-gray-900': isActive,
})} })}
> >
<span className='relative'> <span className='relative'>
<Icon <Icon
src={icon} src={icon}
count={count} count={count}
className={classNames('h-5 w-5 group-hover:text-primary-500', { className={classNames('h-5 w-5', {
'text-primary-500': isActive, '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> </span>

View File

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

View File

@ -1,9 +1,9 @@
.status-content p { .status-content p {
@apply mb-5 whitespace-pre-wrap; @apply mb-4 whitespace-pre-wrap;
} }
.status-content p:last-child { .status-content p:last-child {
@apply mb-0.5; @apply mb-0;
} }
.status-content a { .status-content a {
@ -20,7 +20,7 @@
.status-content ul, .status-content ul,
.status-content ol { .status-content ol {
@apply pl-10 mb-5; @apply pl-10 mb-4;
} }
.status-content ul { .status-content ul {
@ -32,7 +32,7 @@
} }
.status-content blockquote { .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 { .status-content code {
@ -51,7 +51,7 @@
/* Code block */ /* Code block */
.status-content pre { .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 { .status-content pre:last-child {
@ -60,7 +60,7 @@
/* Markdown images */ /* Markdown images */
.status-content img:not(.emojione):not([width][height]) { .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 */ /* 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. // The typical case with a reply-to and a list of mentions.
const accounts = to.slice(0, 2).map(account => { const accounts = to.slice(0, 2).map(account => {
const link = ( 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) { if (hoverable) {

View File

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

View File

@ -35,49 +35,17 @@ const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick }) => (
</button> </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 { interface IStatusContent {
status: Status, status: Status,
expanded?: boolean,
onExpandedToggle?: () => void,
onClick?: () => void, onClick?: () => void,
collapsable?: boolean, collapsable?: boolean,
translatable?: boolean,
} }
/** Renders the text content of a status */ /** Renders the text content of a status */
const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onExpandedToggle, onClick, collapsable = false }) => { const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable = false, translatable }) => {
const history = useHistory(); const history = useHistory();
const [hidden, setHidden] = useState(true);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [onlyEmoji, setOnlyEmoji] = useState(false); const [onlyEmoji, setOnlyEmoji] = useState(false);
@ -179,46 +147,32 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
return; return;
} }
if (deltaX + deltaY < 5 && e.button === 0 && onClick) { if (deltaX + deltaY < 5 && e.button === 0 && !(e.ctrlKey || e.metaKey) && onClick) {
onClick(); onClick();
} }
startXY.current = undefined; 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 parsedHtml = useMemo((): string => {
const { contentHtml: html } = status; const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
if (greentext) { if (greentext) {
return addGreentext(html); return addGreentext(html);
} else { } else {
return html; return html;
} }
}, [status.contentHtml]); }, [status.contentHtml, status.translation]);
if (status.content.length === 0) { if (status.content.length === 0) {
return null; return null;
} }
const isHidden = onExpandedToggle ? !expanded : hidden;
const withSpoiler = status.spoiler_text.length > 0; 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 baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
const content = { __html: parsedHtml }; const content = { __html: parsedHtml };
const spoilerContent = { __html: status.spoilerHtml };
const directionStyle: React.CSSProperties = { direction: 'ltr' }; const directionStyle: React.CSSProperties = { direction: 'ltr' };
const className = classNames(baseClassName, 'status-content', { const className = classNames(baseClassName, 'status-content', {
'cursor-pointer': onClick, 'cursor-pointer': onClick,
@ -231,37 +185,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
directionStyle.direction = 'rtl'; directionStyle.direction = 'rtl';
} }
if (status.spoiler_text.length > 0) { if (onClick) {
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) {
const output = [ const output = [
<div <div
ref={node} ref={node}

View File

@ -81,6 +81,14 @@ const SensitiveContentOverlay = (props: ISensitiveContentOverlay) => {
<Text theme='white' size='sm' weight='medium'> <Text theme='white' size='sm' weight='medium'>
{intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)} {intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)}
</Text> </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> </div>
<HStack alignItems='center' justifyContent='center' space={2}> <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 { connect } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
// import { openModal } from 'soapbox/actions/modals';
// import { useAppDispatch } from 'soapbox/hooks';
import { CardHeader, CardTitle } from './ui'; import { CardHeader, CardTitle } from './ui';
const messages = defineMessages({ const messages = defineMessages({
back: { id: 'column_back_button.label', defaultMessage: 'Back' }, back: { id: 'column_back_button.label', defaultMessage: 'Back' },
settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
}); });
interface ISubNavigation { interface ISubNavigation {
message: String, message: React.ReactNode,
/** @deprecated Unused. */
settings?: React.ComponentType, settings?: React.ComponentType,
} }
const SubNavigation: React.FC<ISubNavigation> = ({ message }) => { const SubNavigation: React.FC<ISubNavigation> = ({ message }) => {
const intl = useIntl(); const intl = useIntl();
// const dispatch = useAppDispatch();
const history = useHistory(); const history = useHistory();
// const ref = useRef(null);
// const [scrolled, setScrolled] = useState(false);
// const onOpenSettings = () => {
// dispatch(openModal('COMPONENT', { component: Settings }));
// };
const handleBackClick = () => { const handleBackClick = () => {
if (window.history && window.history.length === 1) { if (window.history && window.history.length === 1) {
history.push('/'); history.push('/');
@ -40,36 +28,6 @@ const SubNavigation: React.FC<ISubNavigation> = ({ message }) => {
} }
}; };
// const handleBackKeyUp = (e) => {
// if (e.key === 'Enter') {
// handleClick();
// }
// }
// const handleOpenSettings = () => {
// onOpenSettings();
// }
// useEffect(() => {
// const handleScroll = throttle(() => {
// if (this.node) {
// const { offsetTop } = this.node;
// if (offsetTop > 0) {
// setScrolled(true);
// } else {
// setScrolled(false);
// }
// }
// }, 150, { trailing: true });
// window.addEventListener('scroll', handleScroll);
// return () => {
// window.removeEventListener('scroll', handleScroll);
// };
// }, []);
return ( return (
<CardHeader <CardHeader
aria-label={intl.formatMessage(messages.back)} aria-label={intl.formatMessage(messages.back)}

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

View File

@ -20,30 +20,36 @@ const justifyContentOptions = {
}; };
const alignItemsOptions = { const alignItemsOptions = {
top: 'items-start',
bottom: 'items-end',
center: 'items-center', center: 'items-center',
start: 'items-start', start: 'items-start',
end: 'items-end', end: 'items-end',
}; };
interface IStack extends React.HTMLAttributes<HTMLDivElement> { interface IStack extends React.HTMLAttributes<HTMLDivElement> {
/** Size of the gap between elements. */
space?: keyof typeof spaces
/** Horizontal alignment of children. */ /** Horizontal alignment of children. */
alignItems?: 'center' | 'start' | 'end', alignItems?: keyof typeof alignItemsOptions
/** Extra class names on the element. */
className?: string
/** Vertical alignment of children. */ /** Vertical alignment of children. */
justifyContent?: keyof typeof justifyContentOptions justifyContent?: keyof typeof justifyContentOptions
/** Extra class names on the <div> element. */ /** Size of the gap between elements. */
className?: string space?: keyof typeof spaces
/** Whether to let the flexbox grow. */ /** Whether to let the flexbox grow. */
grow?: boolean grow?: boolean
/** HTML element to use for container. */
element?: keyof JSX.IntrinsicElements,
} }
/** Vertical stack of child elements. */ /** Vertical stack of child elements. */
const Stack = React.forwardRef<HTMLDivElement, IStack>((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => { const Stack = React.forwardRef<HTMLDivElement, IStack>((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => {
const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props; const { space, alignItems, justifyContent, className, grow, element = 'div', ...filteredProps } = props;
const Elem = element as 'div';
return ( return (
<div <Elem
{...filteredProps} {...filteredProps}
ref={ref} ref={ref}
className={classNames('flex flex-col items', { 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 { TooltipPopup, useTooltip } from '@reach/tooltip';
import React from 'react'; 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?' }, searchPlaceholder: { id: 'admin.user_index.search_input_placeholder', defaultMessage: 'Who are you looking for?' },
}); });
export default @connect()
@injectIntl
class UserIndex extends ImmutablePureComponent { class UserIndex extends ImmutablePureComponent {
static propTypes = { static propTypes = {
@ -130,3 +128,5 @@ class UserIndex extends ImmutablePureComponent {
} }
} }
export default injectIntl(connect()(UserIndex));

View File

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

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

View File

@ -10,7 +10,6 @@ import {
clearComposeSuggestions, clearComposeSuggestions,
fetchComposeSuggestions, fetchComposeSuggestions,
selectComposeSuggestion, selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose, insertEmojiCompose,
uploadCompose, uploadCompose,
} from 'soapbox/actions/compose'; } from 'soapbox/actions/compose';
@ -38,6 +37,7 @@ import UploadButtonContainer from '../containers/upload_button_container';
import WarningContainer from '../containers/warning_container'; import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter'; import { countableText } from '../util/counter';
import SpoilerInput from './spoiler-input';
import TextCharacterCounter from './text_character_counter'; import TextCharacterCounter from './text_character_counter';
import VisualCharacterCounter from './visual_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?' }, placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' }, pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' }, 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' }, publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
message: { id: 'compose_form.message', defaultMessage: 'Message' }, message: { id: 'compose_form.message', defaultMessage: 'Message' },
@ -134,7 +134,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
setComposeFocused(true); setComposeFocused(true);
}; };
const handleSubmit = () => { const handleSubmit = (e?: React.FormEvent<Element>) => {
if (text !== autosuggestTextareaRef.current?.textarea?.value) { if (text !== autosuggestTextareaRef.current?.textarea?.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly) // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text // Update the state to match the current text
@ -144,6 +144,10 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
// Submit disabled: // Submit disabled:
const fulltext = [spoilerText, countableText(text)].join(''); const fulltext = [spoilerText, countableText(text)].join('');
if (e) {
e.preventDefault();
}
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return; return;
} }
@ -167,10 +171,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text'])); 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) => { const setCursor = (start: number, end: number = start) => {
if (!autosuggestTextareaRef.current?.textarea) return; if (!autosuggestTextareaRef.current?.textarea) return;
autosuggestTextareaRef.current.textarea.setSelectionRange(start, end); autosuggestTextareaRef.current.textarea.setSelectionRange(start, end);
@ -276,7 +276,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
} }
return ( 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 && ( {scheduledStatusCount > 0 && !event && (
<Warning <Warning
message={( message={(
@ -302,30 +302,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
{!shouldCondense && !event && <ReplyMentions composeId={id} />} {!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 <AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef} ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
placeholder={intl.formatMessage(textareaPlaceholder)} placeholder={intl.formatMessage(textareaPlaceholder)}
@ -345,11 +321,19 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
> >
{ {
!condensed && !condensed &&
<div className='compose-form__modifiers'> <Stack space={4} className='compose-form__modifiers'>
<UploadForm composeId={id} /> <UploadForm composeId={id} />
<PollForm composeId={id} /> <PollForm composeId={id} />
<ScheduleFormContainer composeId={id} /> <ScheduleFormContainer composeId={id} />
</div>
<SpoilerInput
composeId={id}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
ref={spoilerTextRef}
/>
</Stack>
} }
</AutosuggestTextarea> </AutosuggestTextarea>
@ -370,7 +354,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
</div> </div>
)} )}
<Button theme='primary' text={publishText} onClick={handleSubmit} disabled={disabledButton} /> <Button type='submit' theme='primary' text={publishText} disabled={disabledButton} />
</div> </div>
</div> </div>
</Stack> </Stack>

View File

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

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 { useCompose } from 'soapbox/hooks';
import SensitiveButton from './sensitive-button';
import Upload from './upload'; import Upload from './upload';
import UploadProgress from './upload-progress'; import UploadProgress from './upload-progress';
@ -28,8 +27,6 @@ const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
<Upload id={id} key={id} composeId={composeId} /> <Upload id={id} key={id} composeId={composeId} />
))} ))}
</div> </div>
{!mediaIds.isEmpty() && <SensitiveButton composeId={composeId} />}
</div> </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> </Text>
</DashWidget> </DashWidget>
<DashWidget to='/developers/sw'>
<SvgIcon src={require('@tabler/icons/script.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.service_worker_label' defaultMessage='Service Worker' />
</Text>
</DashWidget>
<DashWidget onClick={leaveDevelopers}> <DashWidget onClick={leaveDevelopers}>
<SvgIcon src={require('@tabler/icons/logout.svg')} className='text-gray-700 dark:text-gray-600' /> <SvgIcon src={require('@tabler/icons/logout.svg')} className='text-gray-700 dark:text-gray-600' />

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

View File

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

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 React, { useCallback } from 'react';
import { HotKeys } from 'react-hotkeys'; 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 { useHistory } from 'react-router-dom';
import { mentionCompose } from 'soapbox/actions/compose'; import { mentionCompose } from 'soapbox/actions/compose';
@ -58,6 +58,11 @@ const icons: Record<NotificationType, string> = {
'pleroma:participation_accepted': require('@tabler/icons/calendar-event.svg'), 'pleroma:participation_accepted': require('@tabler/icons/calendar-event.svg'),
}; };
const nameMessage = defineMessage({
id: 'notification.name',
defaultMessage: '{link}{others}',
});
const messages: Record<NotificationType, MessageDescriptor> = defineMessages({ const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
follow: { follow: {
id: 'notification.follow', id: 'notification.follow',
@ -130,10 +135,7 @@ const buildMessage = (
instanceTitle: string, instanceTitle: string,
): React.ReactNode => { ): React.ReactNode => {
const link = buildLink(account); const link = buildLink(account);
const name = intl.formatMessage({ const name = intl.formatMessage(nameMessage, {
id: 'notification.name',
defaultMessage: '{link}{others}',
}, {
link, link,
others: totalCount && totalCount > 0 ? ( others: totalCount && totalCount > 0 ? (
<FormattedMessage <FormattedMessage
@ -283,7 +285,7 @@ const Notification: React.FC<INotificaton> = (props) => {
}; };
const renderContent = () => { const renderContent = () => {
switch (type) { switch (type as NotificationType) {
case 'follow': case 'follow':
case 'user_approved': case 'user_approved':
return account && typeof account === 'object' ? ( 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({ const messages = defineMessages({
heading: { id: 'column.preferences', defaultMessage: 'Preferences' }, heading: { id: 'column.preferences', defaultMessage: 'Preferences' },
display_media_default: { id: 'preferences.fields.display_media.default', defaultMessage: 'Hide media marked as sensitive' }, displayPostsDefault: { id: 'preferences.fields.display_media.default', defaultMessage: 'Hide posts marked as sensitive' },
display_media_hide_all: { id: 'preferences.fields.display_media.hide_all', defaultMessage: 'Always hide media' }, displayPostsHideAll: { id: 'preferences.fields.display_media.hide_all', defaultMessage: 'Always hide posts' },
display_media_show_all: { id: 'preferences.fields.display_media.show_all', defaultMessage: 'Always show media' }, displayPostsShowAll: { id: 'preferences.fields.display_media.show_all', defaultMessage: 'Always show posts' },
privacy_public: { id: 'preferences.options.privacy_public', defaultMessage: 'Public' }, privacy_public: { id: 'preferences.options.privacy_public', defaultMessage: 'Public' },
privacy_unlisted: { id: 'preferences.options.privacy_unlisted', defaultMessage: 'Unlisted' }, privacy_unlisted: { id: 'preferences.options.privacy_unlisted', defaultMessage: 'Unlisted' },
privacy_followers_only: { id: 'preferences.options.privacy_followers_only', defaultMessage: 'Followers-only' }, privacy_followers_only: { id: 'preferences.options.privacy_followers_only', defaultMessage: 'Followers-only' },
@ -102,9 +102,9 @@ const Preferences = () => {
}; };
const displayMediaOptions = React.useMemo(() => ({ const displayMediaOptions = React.useMemo(() => ({
default: intl.formatMessage(messages.display_media_default), default: intl.formatMessage(messages.displayPostsDefault),
hide_all: intl.formatMessage(messages.display_media_hide_all), hide_all: intl.formatMessage(messages.displayPostsHideAll),
show_all: intl.formatMessage(messages.display_media_show_all), show_all: intl.formatMessage(messages.displayPostsShowAll),
}), []); }), []);
const defaultPrivacyOptions = React.useMemo(() => ({ const defaultPrivacyOptions = React.useMemo(() => ({
@ -149,7 +149,7 @@ const Preferences = () => {
/> />
</ListItem> </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 <SelectDropdown
items={displayMediaOptions} items={displayMediaOptions}
defaultValue={settings.get('displayMedia') as string | undefined} 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 PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker';
import Timeline from '../ui/components/timeline'; import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Fediverse timeline' }, title: { id: 'column.public', defaultMessage: 'Fediverse timeline' },
dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' }, dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' },
@ -65,8 +63,12 @@ const CommunityTimeline = () => {
return ( return (
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}> <Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} /> <div className='px-4 sm:p-0'>
<SubNavigation message={intl.formatMessage(messages.title)} />
</div>
<PinnedHostsPicker /> <PinnedHostsPicker />
{showExplanationBox && <div className='mb-4'> {showExplanationBox && <div className='mb-4'>
<Accordion <Accordion
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />} headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}

View File

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

View File

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

View File

@ -134,6 +134,7 @@ const Thread: React.FC<IThread> = (props) => {
const me = useAppSelector(state => state.me); const me = useAppSelector(state => state.me);
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId })); const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
const displayMedia = settings.get('displayMedia') as DisplayMedia; const displayMedia = settings.get('displayMedia') as DisplayMedia;
const isUnderReview = status?.visibility === 'self';
const { ancestorsIds, descendantsIds } = useAppSelector(state => { const { ancestorsIds, descendantsIds } = useAppSelector(state => {
let ancestorsIds = ImmutableOrderedSet<string>(); let ancestorsIds = ImmutableOrderedSet<string>();
@ -412,7 +413,7 @@ const Thread: React.FC<IThread> = (props) => {
if (next && status) { if (next && status) {
dispatch(fetchNext(status.id, next)).then(({ next }) => { dispatch(fetchNext(status.id, next)).then(({ next }) => {
setNext(next); setNext(next);
}).catch(() => {}); }).catch(() => { });
} }
}, 300, { leading: true }), [next, status]); }, 300, { leading: true }), [next, status]);
@ -481,14 +482,18 @@ const Thread: React.FC<IThread> = (props) => {
onOpenCompareHistoryModal={handleOpenCompareHistoryModal} onOpenCompareHistoryModal={handleOpenCompareHistoryModal}
/> />
<hr className='mb-2 border-t-2 dark:border-primary-800' /> {!isUnderReview ? (
<>
<hr className='mb-2 border-t-2 dark:border-primary-800' />
<StatusActionBar <StatusActionBar
status={status} status={status}
expandable={false} expandable={false}
space='expand' space='expand'
withLabels withLabels
/> />
</>
) : null}
</div> </div>
</HotKeys> </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} {...compProps}
rel='noopener' rel='noopener'
data-index={i} data-index={i}
className={classNames({ active, destructive })} className={classNames('w-full', { active, destructive })}
data-method={isLogout ? 'delete' : null} data-method={isLogout ? 'delete' : null}
> >
{icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />} {icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -115,6 +115,7 @@ import {
AuthTokenList, AuthTokenList,
EventInformation, EventInformation,
EventDiscussion, EventDiscussion,
ServiceWorkerInfo,
} from './util/async-components'; } from './util/async-components';
import { WrappedRoute } from './util/react_router_helpers'; 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/apps/create' developerOnly page={DefaultPage} component={CreateApp} content={children} />
<WrappedRoute path='/developers/settings_store' developerOnly page={DefaultPage} component={SettingsStore} content={children} /> <WrappedRoute path='/developers/settings_store' developerOnly page={DefaultPage} component={SettingsStore} content={children} />
<WrappedRoute path='/developers/timeline' developerOnly page={DefaultPage} component={TestTimeline} content={children} /> <WrappedRoute path='/developers/timeline' developerOnly page={DefaultPage} component={TestTimeline} content={children} />
<WrappedRoute path='/developers/sw' developerOnly page={DefaultPage} component={ServiceWorkerInfo} content={children} />
<WrappedRoute path='/developers' page={DefaultPage} component={Developers} content={children} /> <WrappedRoute path='/developers' page={DefaultPage} component={Developers} content={children} />
<WrappedRoute path='/error/network' developerOnly page={EmptyPage} component={() => new Promise((_resolve, reject) => reject())} content={children} /> <WrappedRoute path='/error/network' developerOnly page={EmptyPage} component={() => new Promise((_resolve, reject) => reject())} content={children} />
<WrappedRoute path='/error' developerOnly page={EmptyPage} component={IntentionalError} content={children} /> <WrappedRoute path='/error' developerOnly page={EmptyPage} component={IntentionalError} content={children} />

View File

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

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.' }, 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' }, 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.' }, 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 = () => { const Success = () => {
@ -116,30 +121,21 @@ const EmailPassThru = () => {
dispatch(confirmEmailVerification(token)) dispatch(confirmEmailVerification(token))
.then(() => { .then(() => {
setStatus(Statuses.SUCCESS); 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>) => { .catch((error: AxiosError<any>) => {
const errorKey = error?.response?.data?.error; const errorKey = error?.response?.data?.error;
let message = intl.formatMessage({ let message = intl.formatMessage(messages.genericFail);
id: 'email_passthru.fail.generic',
defaultMessage: 'Unable to confirm your email',
});
if (errorKey) { if (errorKey) {
switch (errorKey) { switch (errorKey) {
case 'token_expired': case 'token_expired':
message = intl.formatMessage({ message = intl.formatMessage(messages.tokenExpired);
id: 'email_passthru.fail.expired',
defaultMessage: 'Your email token has expired.',
});
setStatus(Statuses.TOKEN_EXPIRED); setStatus(Statuses.TOKEN_EXPIRED);
break; break;
case 'token_not_found': case 'token_not_found':
message = intl.formatMessage({ message = intl.formatMessage(messages.tokenNotFound);
id: 'email_passthru.fail.not_found', message = intl.formatMessage(messages.invalidToken);
defaultMessage: 'Your email token is invalid.',
});
message = 'Your token is invalid';
setStatus(Statuses.TOKEN_NOT_FOUND); setStatus(Statuses.TOKEN_NOT_FOUND);
break; break;
default: default:

View File

@ -1,6 +1,6 @@
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import React from 'react'; import React from 'react';
import { useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { checkEmailVerification, postEmailVerification, requestEmailVerification } from 'soapbox/actions/verification'; 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 { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; 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 = { const Statuses = {
IDLE: 'IDLE', IDLE: 'IDLE',
REQUESTED: 'REQUESTED', REQUESTED: 'REQUESTED',
@ -77,26 +84,23 @@ const EmailVerification = () => {
dispatch( dispatch(
snackbar.success( snackbar.success(
intl.formatMessage({ intl.formatMessage(messages.verificationSuccess),
id: 'email_verification.exists',
defaultMessage: 'Verification email sent successfully.',
}),
), ),
); );
}) })
.catch((error: AxiosError) => { .catch((error: AxiosError) => {
const errorMessage = (error.response?.data as any)?.error; const errorMessage = (error.response?.data as any)?.error;
const isEmailTaken = errorMessage === 'email_taken'; 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) { if (isEmailTaken) {
message = intl.formatMessage({ id: 'email_verification.exists', defaultMessage: 'This email has already been taken.' }); message = intl.formatMessage(messages.verificationFailTakenAlert);
} else if (errorMessage) { } else if (errorMessage) {
message = errorMessage; message = errorMessage;
} }
if (isEmailTaken) { if (isEmailTaken) {
setErrors([intl.formatMessage({ id: 'email_verification.taken', defaultMessage: 'is taken' })]); setErrors([intl.formatMessage(messages.verificationFailTaken)]);
} }
dispatch(snackbar.error(message)); dispatch(snackbar.error(message));
@ -111,7 +115,9 @@ const EmailVerification = () => {
return ( return (
<div> <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'> <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>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'> <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 { AxiosError } from 'axios';
import React from 'react'; import React from 'react';
import { useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import OtpInput from 'react-otp-input'; import OtpInput from 'react-otp-input';
import snackbar from 'soapbox/actions/snackbar'; 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 { Button, Form, FormGroup, PhoneInput, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; 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 = { const Statuses = {
IDLE: 'IDLE', IDLE: 'IDLE',
REQUESTED: 'REQUESTED', REQUESTED: 'REQUESTED',
@ -38,10 +45,7 @@ const SmsVerification = () => {
setStatus(Statuses.IDLE); setStatus(Statuses.IDLE);
dispatch( dispatch(
snackbar.error( snackbar.error(
intl.formatMessage({ intl.formatMessage(messages.verificationInvalid),
id: 'sms_verification.invalid',
defaultMessage: 'Please enter a valid phone number.',
}),
), ),
); );
return; return;
@ -50,18 +54,12 @@ const SmsVerification = () => {
dispatch(requestPhoneVerification(phone!)).then(() => { dispatch(requestPhoneVerification(phone!)).then(() => {
dispatch( dispatch(
snackbar.success( snackbar.success(
intl.formatMessage({ intl.formatMessage(messages.verificationSuccess),
id: 'sms_verification.success',
defaultMessage: 'A verification code has been sent to your phone number.',
}),
), ),
); );
setStatus(Statuses.REQUESTED); setStatus(Statuses.REQUESTED);
}).catch((error: AxiosError) => { }).catch((error: AxiosError) => {
const message = (error.response?.data as any)?.message || intl.formatMessage({ const message = (error.response?.data as any)?.message || intl.formatMessage(messages.verificationFail);
id: 'sms_verification.fail',
defaultMessage: 'Failed to send SMS message to your phone number.',
});
dispatch(snackbar.error(message)); dispatch(snackbar.error(message));
setStatus(Statuses.FAIL); setStatus(Statuses.FAIL);
@ -78,10 +76,7 @@ const SmsVerification = () => {
dispatch(confirmPhoneVerification(verificationCode)) dispatch(confirmPhoneVerification(verificationCode))
.catch(() => dispatch( .catch(() => dispatch(
snackbar.error( snackbar.error(
intl.formatMessage({ intl.formatMessage(messages.verificationExpired),
id: 'sms_verification.invalid',
defaultMessage: 'Your SMS token has expired.',
}),
), ),
)); ));
}; };
@ -97,7 +92,7 @@ const SmsVerification = () => {
<div> <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'> <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'> <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> </h1>
</div> </div>
@ -136,7 +131,9 @@ const SmsVerification = () => {
return ( return (
<div> <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'> <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>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'> <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.sensitive.unmarked": "Media is not marked as sensitive",
"compose_form.spoiler.marked": "Text is hidden behind warning", "compose_form.spoiler.marked": "Text is hidden behind warning",
"compose_form.spoiler.unmarked": "Text is not hidden", "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", "confirmation_modal.cancel": "Cancel",
"confirmations.admin.deactivate_user.confirm": "Deactivate @{name}", "confirmations.admin.deactivate_user.confirm": "Deactivate @{name}",
"confirmations.admin.deactivate_user.heading": "Deactivate @{acct}", "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.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.boost_modal_label": "Show confirmation dialog before reposting",
"preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post", "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.default": "Hide posts marked as sensitive",
"preferences.fields.display_media.hide_all": "Always hide media", "preferences.fields.display_media.hide_all": "Always hide posts",
"preferences.fields.display_media.show_all": "Always show media", "preferences.fields.display_media.show_all": "Always show posts",
"preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings", "preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings",
"preferences.fields.language_label": "Display Language", "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", "preferences.hints.feed": "In your home feed",
"privacy.change": "Adjust post privacy", "privacy.change": "Adjust post privacy",
"privacy.direct.long": "Post to mentioned users only", "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.expand_spoilers_label": "Zawsze rozwijaj wpisy z ostrzeżeniami o zawartości",
"preferences.fields.language_label": "Język", "preferences.fields.language_label": "Język",
"preferences.fields.media_display_label": "Wyświetlanie zawartości multimedialnej", "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.privacy_label": "Prywatność wpisów",
"preferences.fields.reduce_motion_label": "Ogranicz ruch w animacjach", "preferences.fields.reduce_motion_label": "Ogranicz ruch w animacjach",
"preferences.fields.system_font_label": "Używaj domyślnej czcionki systemu", "preferences.fields.system_font_label": "Używaj domyślnej czcionki systemu",
@ -1211,8 +1211,11 @@
"status.show_less_all": "Zwiń wszystkie", "status.show_less_all": "Zwiń wszystkie",
"status.show_more": "Rozwiń", "status.show_more": "Rozwiń",
"status.show_more_all": "Rozwiń wszystkie", "status.show_more_all": "Rozwiń wszystkie",
"status.show_original": "Pokaż oryginalny wpis",
"status.title": "Wpis", "status.title": "Wpis",
"status.title_direct": "Wiadomość bezpośrednia", "status.title_direct": "Wiadomość bezpośrednia",
"status.translated_from_with": "Przetłumaczono z {lang} z użyciem {provider}",
"status.translate": "Przetłumacz wpis",
"status.unbookmark": "Usuń z zakładek", "status.unbookmark": "Usuń z zakładek",
"status.unbookmarked": "Usunięto z zakładek.", "status.unbookmarked": "Usunięto z zakładek.",
"status.unmute_conversation": "Cofnij wyciszenie konwersacji", "status.unmute_conversation": "Cofnij wyciszenie konwersacji",
@ -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}}", "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.count_by_accounts": "{count} {rawCount, plural, one {osoba rozmawia} few {osoby rozmawiają} other {osób rozmawia}} o tym",
"trends.title": "Trendy", "trends.title": "Trendy",
"trendsPanel.viewAll": "Pokaż wszystkie",
"ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Soapbox.", "ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Soapbox.",
"unauthorized_modal.footer": "Masz już konto? {login}.", "unauthorized_modal.footer": "Masz już konto? {login}.",
"unauthorized_modal.text": "Musisz się zalogować, aby to zrobić.", "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); const result = normalizeInstance(instance);
expect(result.title).toBe('pixelfed'); 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: '', location: '',
locked: false, locked: false,
moved: null as EmbeddedEntity<any>, moved: null as EmbeddedEntity<any>,
mute_expires_at: null as string | null,
note: '', note: '',
pleroma: ImmutableMap<string, any>(), pleroma: ImmutableMap<string, any>(),
source: ImmutableMap<string, any>(), source: ImmutableMap<string, any>(),

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 // Normalize instance (Pleroma, Mastodon, etc.) to Mastodon's format
export const normalizeInstance = (instance: Record<string, any>) => { export const normalizeInstance = (instance: Record<string, any>) => {
return InstanceRecord( return InstanceRecord(
@ -117,6 +128,7 @@ export const normalizeInstance = (instance: Record<string, any>) => {
// Normalize version // Normalize version
normalizeVersion(instance); normalizeVersion(instance);
fixAkkoma(instance);
// Merge defaults // Merge defaults
instance.mergeDeepWith(mergeDefined, InstanceRecord()); instance.mergeDeepWith(mergeDefined, InstanceRecord());

View File

@ -77,6 +77,7 @@ export const StatusRecord = ImmutableRecord({
hidden: false, hidden: false,
search_index: '', search_index: '',
spoilerHtml: '', spoilerHtml: '',
translation: null as ImmutableMap<string, string> | null,
}); });
const normalizeAttachments = (status: ImmutableMap<string, any>) => { const normalizeAttachments = (status: ImmutableMap<string, any>) => {
@ -163,6 +164,13 @@ const fixFiltered = (status: ImmutableMap<string, any>) => {
status.delete('filtered'); 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 // Normalize event
const normalizeEvent = (status: ImmutableMap<string, any>) => { const normalizeEvent = (status: ImmutableMap<string, any>) => {
if (status.getIn(['pleroma', 'event'])) { if (status.getIn(['pleroma', 'event'])) {
@ -184,6 +192,7 @@ export const normalizeStatus = (status: Record<string, any>) => {
addSelfMention(status); addSelfMention(status);
fixQuote(status); fixQuote(status);
fixFiltered(status); fixFiltered(status);
fixSensitivity(status);
normalizeEvent(status); normalizeEvent(status);
}), }),
); );

View File

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

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', () => { 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 = { const action = {
type: actions.COMPOSE_SPOILERNESS_CHANGE, type: actions.COMPOSE_SPOILERNESS_CHANGE,
id: 'home', id: 'home',
@ -218,6 +196,7 @@ describe('compose reducer', () => {
expect(reducer(state, action).toJS().home).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
spoiler: false, spoiler: false,
spoiler_text: '', spoiler_text: '',
sensitive: false,
}); });
}); });

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