-
+
+
{lists.map(list =>
-
+
)}
,
]);
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
index 757cd48fbc..de1db692d1 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
@@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({
},
onLoad (value) {
- return api().get('/api/v2/search', { params: { q: value } }).then(response => {
+ return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
return (response.data.hashtags || []).map((tag) => {
return { value: tag.name, label: `#${tag.name}` };
});
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 40e100fd56..58b8a8cbb1 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -82,28 +82,38 @@ const makeMapStateToProps = () => {
const getDescendantsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'replies']),
- ], (statusId, contextReplies) => {
- let descendantsIds = Immutable.List();
- descendantsIds = descendantsIds.withMutations(mutable => {
- const ids = [statusId];
+ state => state.get('statuses'),
+ ], (statusId, contextReplies, statuses) => {
+ let descendantsIds = [];
+ const ids = [statusId];
- while (ids.length > 0) {
- let id = ids.shift();
- const replies = contextReplies.get(id);
+ while (ids.length > 0) {
+ let id = ids.shift();
+ const replies = contextReplies.get(id);
- if (statusId !== id) {
- mutable.push(id);
- }
-
- if (replies) {
- replies.reverse().forEach(reply => {
- ids.unshift(reply);
- });
- }
+ if (statusId !== id) {
+ descendantsIds.push(id);
}
- });
- return descendantsIds;
+ if (replies) {
+ replies.reverse().forEach(reply => {
+ ids.unshift(reply);
+ });
+ }
+ }
+
+ let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
+ if (insertAt !== -1) {
+ descendantsIds.forEach((id, idx) => {
+ if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
+ descendantsIds.splice(idx, 1);
+ descendantsIds.splice(insertAt, 0, id);
+ insertAt += 1;
+ }
+ });
+ }
+
+ return Immutable.List(descendantsIds);
});
const mapStateToProps = (state, props) => {
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 30097f064e..46df1f4ef4 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -12,7 +12,19 @@ import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error';
-import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from 'flavours/glitch/util/async-components';
+import {
+ Compose,
+ Notifications,
+ HomeTimeline,
+ CommunityTimeline,
+ PublicTimeline,
+ HashtagTimeline,
+ DirectTimeline,
+ FavouritedStatuses,
+ BookmarkedStatuses,
+ ListTimeline,
+ Directory,
+} from 'flavours/glitch/util/async-components';
import ComposePanel from './compose_panel';
import NavigationPanel from './navigation_panel';
@@ -30,6 +42,7 @@ const componentMap = {
'FAVOURITES': FavouritedStatuses,
'BOOKMARKS': BookmarkedStatuses,
'LIST': ListTimeline,
+ 'DIRECTORY': Directory,
};
const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/);
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
index 1712da83e3..588e89a6ad 100644
--- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state';
-import { signOutLink } from 'flavours/glitch/util/backend_links';
+import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links';
import { logOut } from 'flavours/glitch/util/log_out';
import { openModal } from 'flavours/glitch/actions/modal';
@@ -46,7 +46,7 @@ class LinkFooter extends React.PureComponent {
{invitesEnabled && - ·
}
- - ·
+ {!!securityLink && - ·
}
- ·
- ·
- ·
diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
index 4688c77661..df02cafd1d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
@@ -3,6 +3,7 @@ import { NavLink, withRouter } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import Icon from 'flavours/glitch/components/icon';
import { profile_directory } from 'flavours/glitch/util/initial_state';
+import { preferencesLink, relationshipsLink } from 'flavours/glitch/util/backend_links';
import NotificationsCounterIcon from './notifications_counter_icon';
import FollowRequestsNavLink from './follow_requests_nav_link';
import ListPanel from './list_panel';
@@ -16,16 +17,16 @@ const NavigationPanel = ({ onOpenSettings }) => (
+ {profile_directory && }
-
+ {!!preferencesLink && }
-
- {!!profile_directory && }
+ {!!relationshipsLink && }
);
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 33625581df..1feda0b97a 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -46,6 +46,7 @@ import {
Lists,
Search,
GettingStartedMisc,
+ Directory,
} from 'flavours/glitch/util/async-components';
import { HotKeys } from 'react-hotkeys';
import { me } from 'flavours/glitch/util/initial_state';
@@ -185,6 +186,7 @@ class SwitchingColumnsArea extends React.PureComponent {
+
diff --git a/app/javascript/flavours/glitch/reducers/polls.js b/app/javascript/flavours/glitch/reducers/polls.js
index 9956cf83f6..595f340bc7 100644
--- a/app/javascript/flavours/glitch/reducers/polls.js
+++ b/app/javascript/flavours/glitch/reducers/polls.js
@@ -1,4 +1,4 @@
-import { POLLS_IMPORT } from 'mastodon/actions/importer';
+import { POLLS_IMPORT } from 'flavours/glitch/actions/importer';
import { Map as ImmutableMap, fromJS } from 'immutable';
const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll))));
diff --git a/app/javascript/flavours/glitch/reducers/suggestions.js b/app/javascript/flavours/glitch/reducers/suggestions.js
index 834be728f1..a08fedc25c 100644
--- a/app/javascript/flavours/glitch/reducers/suggestions.js
+++ b/app/javascript/flavours/glitch/reducers/suggestions.js
@@ -4,8 +4,8 @@ import {
SUGGESTIONS_FETCH_FAIL,
SUGGESTIONS_DISMISS,
} from '../actions/suggestions';
-import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
-import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
+import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts';
+import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({
diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js
index a4df9ec8de..b4e1d1eae4 100644
--- a/app/javascript/flavours/glitch/reducers/user_lists.js
+++ b/app/javascript/flavours/glitch/reducers/user_lists.js
@@ -20,6 +20,14 @@ import {
MUTES_FETCH_SUCCESS,
MUTES_EXPAND_SUCCESS,
} from 'flavours/glitch/actions/mutes';
+import {
+ DIRECTORY_FETCH_REQUEST,
+ DIRECTORY_FETCH_SUCCESS,
+ DIRECTORY_FETCH_FAIL,
+ DIRECTORY_EXPAND_REQUEST,
+ DIRECTORY_EXPAND_SUCCESS,
+ DIRECTORY_EXPAND_FAIL,
+} from 'flavours/glitch/actions/directory';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
const initialState = ImmutableMap({
@@ -74,6 +82,16 @@ export default function userLists(state = initialState, action) {
return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
case MUTES_EXPAND_SUCCESS:
return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+ case DIRECTORY_FETCH_SUCCESS:
+ return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
+ case DIRECTORY_EXPAND_SUCCESS:
+ return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
+ case DIRECTORY_FETCH_REQUEST:
+ case DIRECTORY_EXPAND_REQUEST:
+ return state.setIn(['directory', 'isLoading'], true);
+ case DIRECTORY_FETCH_FAIL:
+ case DIRECTORY_EXPAND_FAIL:
+ return state.setIn(['directory', 'isLoading'], false);
default:
return state;
}
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index d2233207d7..dc49e083c5 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -415,6 +415,24 @@
}
}
}
+
+ &.directory__section-headline {
+ background: darken($ui-base-color, 2%);
+ border-bottom-color: transparent;
+
+ a,
+ button {
+ &.active {
+ &::before {
+ display: none;
+ }
+
+ &::after {
+ border-color: transparent transparent darken($ui-base-color, 7%);
+ }
+ }
+ }
+ }
}
.account__moved-note {
diff --git a/app/javascript/flavours/glitch/styles/components/directory.scss b/app/javascript/flavours/glitch/styles/components/directory.scss
new file mode 100644
index 0000000000..b0ad5a88ae
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/directory.scss
@@ -0,0 +1,180 @@
+.directory {
+ &__list {
+ width: 100%;
+ margin: 10px 0;
+ transition: opacity 100ms ease-in;
+
+ &.loading {
+ opacity: 0.7;
+ }
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ margin: 0;
+ }
+ }
+
+ &__card {
+ box-sizing: border-box;
+ margin-bottom: 10px;
+
+ &__img {
+ height: 125px;
+ position: relative;
+ background: darken($ui-base-color, 12%);
+ overflow: hidden;
+
+ img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ object-fit: cover;
+ }
+ }
+
+ &__bar {
+ display: flex;
+ align-items: center;
+ background: lighten($ui-base-color, 4%);
+ padding: 10px;
+
+ &__name {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ text-decoration: none;
+ overflow: hidden;
+ }
+
+ &__relationship {
+ width: 23px;
+ min-height: 1px;
+ flex: 0 0 auto;
+ }
+
+ .avatar {
+ flex: 0 0 auto;
+ width: 48px;
+ height: 48px;
+ padding-top: 2px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ margin: 0;
+ border-radius: 4px;
+ background: darken($ui-base-color, 8%);
+ object-fit: cover;
+ }
+ }
+
+ .display-name {
+ margin-left: 15px;
+ text-align: left;
+
+ strong {
+ font-size: 15px;
+ color: $primary-text-color;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ span {
+ display: block;
+ font-size: 14px;
+ color: $darker-text-color;
+ font-weight: 400;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+
+ &__extra {
+ background: $ui-base-color;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .accounts-table__count {
+ width: 33.33%;
+ flex: 0 0 auto;
+ padding: 15px 0;
+ }
+
+ .account__header__content {
+ box-sizing: border-box;
+ padding: 15px 10px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ width: 100%;
+ min-height: 18px + 30px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ p {
+ display: none;
+
+ &:first-child {
+ display: inline;
+ }
+ }
+
+ br {
+ display: none;
+ }
+ }
+ }
+ }
+}
+
+.filter-form {
+ background: $ui-base-color;
+
+ &__column {
+ padding: 10px 15px;
+ }
+
+ .radio-button {
+ display: block;
+ }
+}
+
+.radio-button {
+ font-size: 14px;
+ position: relative;
+ display: inline-block;
+ padding: 6px 0;
+ line-height: 18px;
+ cursor: default;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+
+ input[type=radio],
+ input[type=checkbox] {
+ display: none;
+ }
+
+ &__input {
+ display: inline-block;
+ position: relative;
+ border: 1px solid $ui-primary-color;
+ box-sizing: border-box;
+ width: 18px;
+ height: 18px;
+ flex: 0 0 auto;
+ margin-right: 10px;
+ top: -1px;
+ border-radius: 50%;
+ vertical-align: middle;
+
+ &.checked {
+ border-color: lighten($ui-highlight-color, 8%);
+ background: lighten($ui-highlight-color, 8%);
+ }
+ }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index f453a046e6..9f59c81ff8 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -1467,6 +1467,7 @@ noscript {
@import 'composer';
@import 'columns';
@import 'regeneration_indicator';
+@import 'directory';
@import 'search';
@import 'emoji';
@import 'doodle';
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index 6dee7725c0..85982d9381 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -669,38 +669,13 @@
}
}
-&.detailed,
-&.fullscreen {
- .video-player__buttons {
- button {
- padding-top: 10px;
- padding-bottom: 10px;
+ &.detailed,
+ &.fullscreen {
+ .video-player__buttons {
+ button {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
}
}
}
-}
-
-.media-spoiler-video {
- background-size: cover;
- background-repeat: no-repeat;
- background-position: center;
- cursor: pointer;
- margin-top: 8px;
- position: relative;
-
- @include fullwidth-gallery;
-
- border: 0;
- display: block;
-}
-
-.media-spoiler-video-play-icon {
- border-radius: 100px;
- color: rgba($primary-text-color, 0.8);
- font-size: 36px;
- left: 50%;
- padding: 5px;
- position: absolute;
- top: 50%;
- transform: translate(-50%, -50%);
-}
diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss
index d22cd4a8b0..aeb0abb550 100644
--- a/app/javascript/flavours/glitch/styles/components/single_column.scss
+++ b/app/javascript/flavours/glitch/styles/components/single_column.scss
@@ -83,6 +83,24 @@
padding: 0;
}
+ .directory__list {
+ display: grid;
+ grid-gap: 10px;
+ grid-template-columns: minmax(0, 50%) minmax(0, 50%);
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ display: block;
+ }
+ }
+
+ .directory__card {
+ margin-bottom: 0;
+ }
+
+ .filter-form {
+ display: flex;
+ }
+
.autosuggest-textarea__textarea {
font-size: 16px;
}
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 2c7c1e8aaf..24ab719691 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -886,67 +886,6 @@ a.status-card.compact:hover {
background-position: center center;
}
-.status__video-player {
- display: flex;
- align-items: center;
- background: $base-shadow-color;
- box-sizing: border-box;
- cursor: default; /* May not be needed */
- margin-top: 8px;
- overflow: hidden;
- position: relative;
-
- @include fullwidth-gallery;
-}
-
-.status__video-player-video {
- height: 100%;
- object-fit: contain;
- position: relative;
- top: 50%;
- transform: translateY(-50%);
- width: 100%;
- z-index: 1;
-
- &:not(.letterbox) {
- height: 100%;
- object-fit: cover;
- }
-}
-
-.status__video-player-expand,
-.status__video-player-mute {
- color: $primary-text-color;
- opacity: 0.8;
- position: absolute;
- right: 4px;
- text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
-}
-
-.status__video-player-spoiler {
- display: none;
- color: $primary-text-color;
- left: 4px;
- position: absolute;
- text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
- top: 4px;
- z-index: 100;
-
- &.status__video-player-spoiler--visible {
- display: block;
- }
-}
-
-.status__video-player-expand {
- bottom: 4px;
- z-index: 100;
-}
-
-.status__video-player-mute {
- top: 4px;
- z-index: 5;
-}
-
.attachment-list {
display: flex;
font-size: 14px;
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index 130e1461cc..45eb5a9d09 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -769,6 +769,24 @@
}
}
+ .directory__list {
+ display: grid;
+ grid-gap: 10px;
+ grid-template-columns: minmax(0, 50%) minmax(0, 50%);
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ display: block;
+ }
+
+ .icon-button {
+ font-size: 18px;
+ }
+ }
+
+ .directory__card {
+ margin-bottom: 0;
+ }
+
.card-grid {
display: flex;
flex-wrap: wrap;
diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js
index 5050f0ff74..6c0acdb275 100644
--- a/app/javascript/flavours/glitch/util/async-components.js
+++ b/app/javascript/flavours/glitch/util/async-components.js
@@ -161,3 +161,7 @@ export function Search () {
export function Tesseract () {
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
}
+
+export function Directory () {
+ return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory');
+}
diff --git a/app/javascript/flavours/glitch/util/backend_links.js b/app/javascript/flavours/glitch/util/backend_links.js
index bc82197bec..0fb378cc10 100644
--- a/app/javascript/flavours/glitch/util/backend_links.js
+++ b/app/javascript/flavours/glitch/util/backend_links.js
@@ -5,3 +5,5 @@ export const termsLink = '/terms';
export const accountAdminLink = (id) => `/admin/accounts/${id}`;
export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`;
export const filterEditLink = (id) => `/filters/${id}/edit`;
+export const relationshipsLink = '/relationships';
+export const securityLink = '/auth/edit';
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index 4b6227cac6..a537b0df91 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -26,6 +26,7 @@ export const pollLimits = (initialState && initialState.poll_limits);
export const invitesEnabled = getMeta('invites_enabled');
export const version = getMeta('version');
export const mascot = getMeta('mascot');
+export const profile_directory = getMeta('profile_directory');
export const isStaff = getMeta('is_staff');
export const defaultContentType = getMeta('default_content_type');
export const forceSingleColumn = getMeta('advanced_layout') === false;
diff --git a/app/javascript/mastodon/actions/directory.js b/app/javascript/mastodon/actions/directory.js
new file mode 100644
index 0000000000..4b2b6dd56d
--- /dev/null
+++ b/app/javascript/mastodon/actions/directory.js
@@ -0,0 +1,61 @@
+import api from '../api';
+import { importFetchedAccounts } from './importer';
+import { fetchRelationships } from './accounts';
+
+export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
+export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
+export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL';
+
+export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
+export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
+export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL';
+
+export const fetchDirectory = params => (dispatch, getState) => {
+ dispatch(fetchDirectoryRequest());
+
+ api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
+ dispatch(importFetchedAccounts(data));
+ dispatch(fetchDirectorySuccess(data));
+ dispatch(fetchRelationships(data.map(x => x.id)));
+ }).catch(error => dispatch(fetchDirectoryFail(error)));
+};
+
+export const fetchDirectoryRequest = () => ({
+ type: DIRECTORY_FETCH_REQUEST,
+});
+
+export const fetchDirectorySuccess = accounts => ({
+ type: DIRECTORY_FETCH_SUCCESS,
+ accounts,
+});
+
+export const fetchDirectoryFail = error => ({
+ type: DIRECTORY_FETCH_FAIL,
+ error,
+});
+
+export const expandDirectory = params => (dispatch, getState) => {
+ dispatch(expandDirectoryRequest());
+
+ const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
+
+ api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
+ dispatch(importFetchedAccounts(data));
+ dispatch(expandDirectorySuccess(data));
+ dispatch(fetchRelationships(data.map(x => x.id)));
+ }).catch(error => dispatch(expandDirectoryFail(error)));
+};
+
+export const expandDirectoryRequest = () => ({
+ type: DIRECTORY_EXPAND_REQUEST,
+});
+
+export const expandDirectorySuccess = accounts => ({
+ type: DIRECTORY_EXPAND_SUCCESS,
+ accounts,
+});
+
+export const expandDirectoryFail = error => ({
+ type: DIRECTORY_EXPAND_FAIL,
+ error,
+});
diff --git a/app/javascript/mastodon/components/radio_button.js b/app/javascript/mastodon/components/radio_button.js
new file mode 100644
index 0000000000..0496fa2868
--- /dev/null
+++ b/app/javascript/mastodon/components/radio_button.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class RadioButton extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.string.isRequired,
+ checked: PropTypes.bool,
+ name: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ label: PropTypes.node.isRequired,
+ };
+
+ render () {
+ const { name, value, checked, onChange, label } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js
new file mode 100644
index 0000000000..cb23a02bad
--- /dev/null
+++ b/app/javascript/mastodon/features/directory/components/account_card.js
@@ -0,0 +1,149 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'mastodon/selectors';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+import Permalink from 'mastodon/components/permalink';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+import IconButton from 'mastodon/components/icon_button';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
+import { shortNumberFormat } from 'mastodon/utils/numbers';
+import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
+import { openModal } from 'mastodon/actions/modal';
+import { initMuteModal } from 'mastodon/actions/mutes';
+
+const messages = defineMessages({
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { id }) => ({
+ account: getAccount(state, id),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onFollow (account) {
+ if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+ if (unfollowModal) {
+ dispatch(openModal('CONFIRM', {
+ message:
@{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.unfollowConfirm),
+ onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+ }));
+ } else {
+ dispatch(unfollowAccount(account.get('id')));
+ }
+ } else {
+ dispatch(followAccount(account.get('id')));
+ }
+ },
+
+ onBlock (account) {
+ if (account.getIn(['relationship', 'blocking'])) {
+ dispatch(unblockAccount(account.get('id')));
+ } else {
+ dispatch(blockAccount(account.get('id')));
+ }
+ },
+
+ onMute (account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ } else {
+ dispatch(initMuteModal(account));
+ }
+ },
+
+});
+
+export default @injectIntl
+@connect(makeMapStateToProps, mapDispatchToProps)
+class AccountCard extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ };
+
+ handleFollow = () => {
+ this.props.onFollow(this.props.account);
+ }
+
+ handleBlock = () => {
+ this.props.onBlock(this.props.account);
+ }
+
+ handleMute = () => {
+ this.props.onMute(this.props.account);
+ }
+
+ render () {
+ const { account, intl } = this.props;
+
+ let buttons;
+
+ if (account.get('id') !== me && account.get('relationship', null) !== null) {
+ const following = account.getIn(['relationship', 'following']);
+ const requested = account.getIn(['relationship', 'requested']);
+ const blocking = account.getIn(['relationship', 'blocking']);
+ const muting = account.getIn(['relationship', 'muting']);
+
+ if (requested) {
+ buttons = ;
+ } else if (blocking) {
+ buttons = ;
+ } else if (muting) {
+ buttons = ;
+ } else if (!account.get('moved') || following) {
+ buttons = ;
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {buttons}
+
+
+
+
+
+
+
{shortNumberFormat(account.get('statuses_count'))}
+
{shortNumberFormat(account.get('followers_count'))}
+
{account.get('last_status_at') === null ? : }
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/directory/index.js b/app/javascript/mastodon/features/directory/index.js
new file mode 100644
index 0000000000..2f91e759b5
--- /dev/null
+++ b/app/javascript/mastodon/features/directory/index.js
@@ -0,0 +1,171 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
+import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
+import { List as ImmutableList } from 'immutable';
+import AccountCard from './components/account_card';
+import RadioButton from 'mastodon/components/radio_button';
+import classNames from 'classnames';
+import LoadMore from 'mastodon/components/load_more';
+import { ScrollContainer } from 'react-router-scroll-4';
+
+const messages = defineMessages({
+ title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
+ recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
+ newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
+ local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
+ federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
+ isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
+ domain: state.getIn(['meta', 'domain']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Directory extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ isLoading: PropTypes.bool,
+ accountIds: ImmutablePropTypes.list.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ shouldUpdateScroll: PropTypes.func,
+ columnId: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ multiColumn: PropTypes.bool,
+ domain: PropTypes.string.isRequired,
+ params: PropTypes.shape({
+ order: PropTypes.string,
+ local: PropTypes.bool,
+ }),
+ };
+
+ state = {
+ order: null,
+ local: null,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
+ }
+ }
+
+ getParams = (props, state) => ({
+ order: state.order === null ? (props.params.order || 'active') : state.order,
+ local: state.local === null ? (props.params.local || false) : state.local,
+ });
+
+ handleMove = dir => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ dispatch(fetchDirectory(this.getParams(this.props, this.state)));
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ const { dispatch } = this.props;
+ const paramsOld = this.getParams(prevProps, prevState);
+ const paramsNew = this.getParams(this.props, this.state);
+
+ if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
+ dispatch(fetchDirectory(paramsNew));
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleChangeOrder = e => {
+ const { dispatch, columnId } = this.props;
+
+ if (columnId) {
+ dispatch(changeColumnParams(columnId, ['order'], e.target.value));
+ } else {
+ this.setState({ order: e.target.value });
+ }
+ }
+
+ handleChangeLocal = e => {
+ const { dispatch, columnId } = this.props;
+
+ if (columnId) {
+ dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
+ } else {
+ this.setState({ local: e.target.value === '1' });
+ }
+ }
+
+ handleLoadMore = () => {
+ const { dispatch } = this.props;
+ dispatch(expandDirectory(this.getParams(this.props, this.state)));
+ }
+
+ render () {
+ const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
+ const { order, local } = this.getParams(this.props, this.state);
+ const pinned = !!columnId;
+
+ const scrollableArea = (
+
+
+
+
+ {accountIds.map(accountId =>
)}
+
+
+
+
+ );
+
+ return (
+
+
+
+ {multiColumn && !pinned ? {scrollableArea} : scrollableArea}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 6a122a750b..f6d90580b6 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -107,7 +107,7 @@ class GettingStarted extends ImmutablePureComponent {
if (profile_directory) {
navItems.push(
-
+
);
height += 48;
@@ -120,7 +120,7 @@ class GettingStarted extends ImmutablePureComponent {
height += 34;
} else if (profile_directory) {
navItems.push(
-
+
);
height += 48;
diff --git a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js
index c5098052ce..5914bbeaf7 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js
@@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({
},
onLoad (value) {
- return api().get('/api/v2/search', { params: { q: value } }).then(response => {
+ return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
return (response.data.hashtags || []).map((tag) => {
return { value: tag.name, label: `#${tag.name}` };
});
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index ad4f758207..f78a9489ab 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -84,28 +84,38 @@ const makeMapStateToProps = () => {
const getDescendantsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'replies']),
- ], (statusId, contextReplies) => {
- let descendantsIds = Immutable.List();
- descendantsIds = descendantsIds.withMutations(mutable => {
- const ids = [statusId];
+ state => state.get('statuses'),
+ ], (statusId, contextReplies, statuses) => {
+ let descendantsIds = [];
+ const ids = [statusId];
- while (ids.length > 0) {
- let id = ids.shift();
- const replies = contextReplies.get(id);
+ while (ids.length > 0) {
+ let id = ids.shift();
+ const replies = contextReplies.get(id);
- if (statusId !== id) {
- mutable.push(id);
- }
-
- if (replies) {
- replies.reverse().forEach(reply => {
- ids.unshift(reply);
- });
- }
+ if (statusId !== id) {
+ descendantsIds.push(id);
}
- });
- return descendantsIds;
+ if (replies) {
+ replies.reverse().forEach(reply => {
+ ids.unshift(reply);
+ });
+ }
+ }
+
+ let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
+ if (insertAt !== -1) {
+ descendantsIds.forEach((id, idx) => {
+ if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
+ descendantsIds.splice(idx, 1);
+ descendantsIds.splice(insertAt, 0, id);
+ insertAt += 1;
+ }
+ });
+ }
+
+ return Immutable.List(descendantsIds);
});
const mapStateToProps = (state, props) => {
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 042e44e43e..8a4e89b3de 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -12,7 +12,18 @@ import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error';
-import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components';
+import {
+ Compose,
+ Notifications,
+ HomeTimeline,
+ CommunityTimeline,
+ PublicTimeline,
+ HashtagTimeline,
+ DirectTimeline,
+ FavouritedStatuses,
+ ListTimeline,
+ Directory,
+} from '../../ui/util/async-components';
import Icon from 'mastodon/components/icon';
import ComposePanel from './compose_panel';
import NavigationPanel from './navigation_panel';
@@ -30,6 +41,7 @@ const componentMap = {
'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses,
'LIST': ListTimeline,
+ 'DIRECTORY': Directory,
};
const messages = defineMessages({
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js
index 64a40a9da8..6f07778f24 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.js
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js
@@ -18,6 +18,7 @@ const NavigationPanel = () => (
+ {profile_directory && }
@@ -25,7 +26,6 @@ const NavigationPanel = () => (
- {!!profile_directory && }
{showTrends && }
{showTrends && }
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 9d284c2216..49c5c8d0ec 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -47,6 +47,7 @@ import {
PinnedStatuses,
Lists,
Search,
+ Directory,
} from './util/async-components';
import { me, forceSingleColumn } from '../../initial_state';
import { previewState as previewMediaState } from './components/media_modal';
@@ -188,6 +189,7 @@ class SwitchingColumnsArea extends React.PureComponent {
+
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index a9b95c7b80..0084c15103 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -141,3 +141,7 @@ export function Tesseract () {
export function Audio () {
return import(/* webpackChunkName: "features/audio" */'../../audio');
}
+
+export function Directory () {
+ return import(/* webpackChunkName: "features/directory" */'../../directory');
+}
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
index 8db18c5dc6..08e94022f1 100644
--- a/app/javascript/mastodon/reducers/user_lists.js
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -20,6 +20,14 @@ import {
MUTES_FETCH_SUCCESS,
MUTES_EXPAND_SUCCESS,
} from '../actions/mutes';
+import {
+ DIRECTORY_FETCH_REQUEST,
+ DIRECTORY_FETCH_SUCCESS,
+ DIRECTORY_FETCH_FAIL,
+ DIRECTORY_EXPAND_REQUEST,
+ DIRECTORY_EXPAND_SUCCESS,
+ DIRECTORY_EXPAND_FAIL,
+} from 'mastodon/actions/directory';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
const initialState = ImmutableMap({
@@ -74,6 +82,16 @@ export default function userLists(state = initialState, action) {
return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
case MUTES_EXPAND_SUCCESS:
return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+ case DIRECTORY_FETCH_SUCCESS:
+ return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
+ case DIRECTORY_EXPAND_SUCCESS:
+ return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
+ case DIRECTORY_FETCH_REQUEST:
+ case DIRECTORY_EXPAND_REQUEST:
+ return state.setIn(['directory', 'isLoading'], true);
+ case DIRECTORY_FETCH_FAIL:
+ case DIRECTORY_EXPAND_FAIL:
+ return state.setIn(['directory', 'isLoading'], false);
default:
return state;
}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 8aaa068d38..fd2180d6f4 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -2092,13 +2092,23 @@ a.account__display-name {
padding: 0;
}
- //.column {
- // margin-top: 0;
+ .directory__list {
+ display: grid;
+ grid-gap: 10px;
+ grid-template-columns: minmax(0, 50%) minmax(0, 50%);
- // @media screen and (min-width: $no-gap-breakpoint) {
- // margin-top: 10px;
- // }
- //}
+ @media screen and (max-width: $no-gap-breakpoint) {
+ display: block;
+ }
+ }
+
+ .directory__card {
+ margin-bottom: 0;
+ }
+
+ .filter-form {
+ display: flex;
+ }
.autosuggest-textarea__textarea {
font-size: 16px;
@@ -4982,59 +4992,6 @@ a.status-card.compact:hover {
}
/* End Media Gallery */
-/* Status Video Player */
-.status__video-player {
- background: $base-overlay-background;
- box-sizing: border-box;
- cursor: default; /* May not be needed */
- margin-top: 8px;
- overflow: hidden;
- position: relative;
-}
-
-.status__video-player-video {
- height: 100%;
- object-fit: cover;
- position: relative;
- top: 50%;
- transform: translateY(-50%);
- width: 100%;
- z-index: 1;
-}
-
-.status__video-player-expand,
-.status__video-player-mute {
- color: $primary-text-color;
- opacity: 0.8;
- position: absolute;
- right: 4px;
- text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
-}
-
-.status__video-player-spoiler {
- display: none;
- color: $primary-text-color;
- left: 4px;
- position: absolute;
- text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
- top: 4px;
- z-index: 100;
-
- &.status__video-player-spoiler--visible {
- display: block;
- }
-}
-
-.status__video-player-expand {
- bottom: 4px;
- z-index: 100;
-}
-
-.status__video-player-mute {
- top: 4px;
- z-index: 5;
-}
-
.detailed,
.fullscreen {
.video-player__volume__current,
@@ -5387,28 +5344,137 @@ a.status-card.compact:hover {
}
}
-.media-spoiler-video {
- background-size: cover;
- background-repeat: no-repeat;
- background-position: center;
- cursor: pointer;
- margin-top: 8px;
- position: relative;
- border: 0;
- display: block;
-}
+.directory {
+ &__list {
+ width: 100%;
+ margin: 10px 0;
+ transition: opacity 100ms ease-in;
-.media-spoiler-video-play-icon {
- border-radius: 100px;
- color: rgba($primary-text-color, 0.8);
- font-size: 36px;
- left: 50%;
- padding: 5px;
- position: absolute;
- top: 50%;
- transform: translate(-50%, -50%);
+ &.loading {
+ opacity: 0.7;
+ }
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ margin: 0;
+ }
+ }
+
+ &__card {
+ box-sizing: border-box;
+ margin-bottom: 10px;
+
+ &__img {
+ height: 125px;
+ position: relative;
+ background: darken($ui-base-color, 12%);
+ overflow: hidden;
+
+ img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ object-fit: cover;
+ }
+ }
+
+ &__bar {
+ display: flex;
+ align-items: center;
+ background: lighten($ui-base-color, 4%);
+ padding: 10px;
+
+ &__name {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ text-decoration: none;
+ overflow: hidden;
+ }
+
+ &__relationship {
+ width: 23px;
+ min-height: 1px;
+ flex: 0 0 auto;
+ }
+
+ .avatar {
+ flex: 0 0 auto;
+ width: 48px;
+ height: 48px;
+ padding-top: 2px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ margin: 0;
+ border-radius: 4px;
+ background: darken($ui-base-color, 8%);
+ object-fit: cover;
+ }
+ }
+
+ .display-name {
+ margin-left: 15px;
+ text-align: left;
+
+ strong {
+ font-size: 15px;
+ color: $primary-text-color;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ span {
+ display: block;
+ font-size: 14px;
+ color: $darker-text-color;
+ font-weight: 400;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+
+ &__extra {
+ background: $ui-base-color;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .accounts-table__count {
+ width: 33.33%;
+ flex: 0 0 auto;
+ padding: 15px 0;
+ }
+
+ .account__header__content {
+ box-sizing: border-box;
+ padding: 15px 10px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ width: 100%;
+ min-height: 18px + 30px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ p {
+ display: none;
+
+ &:first-child {
+ display: inline;
+ }
+ }
+
+ br {
+ display: none;
+ }
+ }
+ }
+ }
}
-/* End Video Player */
.account-gallery__container {
display: flex;
@@ -5484,6 +5550,73 @@ a.status-card.compact:hover {
}
}
}
+
+ &.directory__section-headline {
+ background: darken($ui-base-color, 2%);
+ border-bottom-color: transparent;
+
+ a,
+ button {
+ &.active {
+ &::before {
+ display: none;
+ }
+
+ &::after {
+ border-color: transparent transparent darken($ui-base-color, 7%);
+ }
+ }
+ }
+ }
+}
+
+.filter-form {
+ background: $ui-base-color;
+
+ &__column {
+ padding: 10px 15px;
+ }
+
+ .radio-button {
+ display: block;
+ }
+}
+
+.radio-button {
+ font-size: 14px;
+ position: relative;
+ display: inline-block;
+ padding: 6px 0;
+ line-height: 18px;
+ cursor: default;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+
+ input[type=radio],
+ input[type=checkbox] {
+ display: none;
+ }
+
+ &__input {
+ display: inline-block;
+ position: relative;
+ border: 1px solid $ui-primary-color;
+ box-sizing: border-box;
+ width: 18px;
+ height: 18px;
+ flex: 0 0 auto;
+ margin-right: 10px;
+ top: -1px;
+ border-radius: 50%;
+ vertical-align: middle;
+
+ &.checked {
+ border-color: lighten($ui-highlight-color, 8%);
+ background: lighten($ui-highlight-color, 8%);
+ }
+ }
}
::-webkit-scrollbar-thumb {
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 2b6794ee2c..e769c495b1 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -763,6 +763,24 @@
}
}
+ .directory__list {
+ display: grid;
+ grid-gap: 10px;
+ grid-template-columns: minmax(0, 50%) minmax(0, 50%);
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ display: block;
+ }
+
+ .icon-button {
+ font-size: 18px;
+ }
+ }
+
+ .directory__card {
+ margin-bottom: 0;
+ }
+
.card-grid {
display: flex;
flex-wrap: wrap;
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index a1d84de2fb..1c58be8c0f 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -20,6 +20,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
+ discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
}.freeze
def self.default_key_transform
diff --git a/app/models/account.rb b/app/models/account.rb
index 9d938c55d6..918b174302 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -51,7 +51,6 @@
class Account < ApplicationRecord
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
- MIN_FOLLOWERS_DISCOVERY = 10
include AccountAssociations
include AccountAvatar
@@ -104,11 +103,13 @@ class Account < ApplicationRecord
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
- scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) }
+ scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
- scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) }
+ scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
scope :popular, -> { order('account_stats.followers_count desc') }
scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
+ scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
+ scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
delegate :email,
:unconfirmed_email,
diff --git a/app/models/feed.rb b/app/models/feed.rb
index 0e8943ff86..36e0c1e0a0 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -9,6 +9,11 @@ class Feed
end
def get(limit, max_id = nil, since_id = nil, min_id = nil)
+ limit = limit.to_i
+ max_id = max_id.to_i if max_id.present?
+ since_id = since_id.to_i if since_id.present?
+ min_id = min_id.to_i if min_id.present?
+
from_redis(limit, max_id, since_id, min_id)
end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index d03751fd3e..83d1858aa1 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -28,12 +28,12 @@ class MediaAttachment < ApplicationRecord
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
- AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp).freeze
+ AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze
VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze
VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze
- AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/3gpp).freeze
+ AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
BLURHASH_OPTIONS = {
x_comp: 4,
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 93df117242..52dd3f67ba 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -6,7 +6,7 @@ class RemoteFollow
attr_accessor :acct, :addressable_template
- validates :acct, presence: true
+ validates :acct, presence: true, domain: { acct: true }
def initialize(attrs = {})
@acct = normalize_acct(attrs[:acct])
@@ -21,7 +21,7 @@ class RemoteFollow
end
def subscribe_address_for(account)
- addressable_template.expand(uri: account.local_username_and_domain).to_s
+ addressable_template.expand(uri: ActivityPub::TagManager.instance.uri_for(account)).to_s
end
def interact_address_for(status)
@@ -44,6 +44,8 @@ class RemoteFollow
end
[username, domain].compact.join('@')
+ rescue Addressable::URI::InvalidURIError
+ value
end
def fetch_template!
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index 0bd7aed2e9..222e17c994 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -6,12 +6,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context :security
context_extensions :manually_approves_followers, :featured, :also_known_as,
- :moved_to, :property_value, :hashtag, :emoji, :identity_proof
+ :moved_to, :property_value, :hashtag, :emoji, :identity_proof,
+ :discoverable
attributes :id, :type, :following, :followers,
:inbox, :outbox, :featured,
:preferred_username, :name, :summary,
- :url, :manually_approves_followers
+ :url, :manually_approves_followers,
+ :discoverable
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 3ecce8f0a0..63b84a0b9d 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at,
:note, :url, :avatar, :avatar_static, :header, :header_static,
- :followers_count, :following_count, :statuses_count
+ :followers_count, :following_count, :statuses_count, :last_status_at
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
has_many :emojis, serializer: REST::CustomEmojiSerializer
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 603e27ed97..cef658e191 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -83,6 +83,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.fields = property_values || {}
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
@account.actor_type = actor_type
+ @account.discoverable = @json['discoverable'] || false
end
def set_fetchable_attributes!
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index b364713394..5d17f111b5 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -49,7 +49,13 @@ class PostStatusService < BaseService
def preprocess_attributes!
if @text.blank? && @options[:spoiler_text].present?
@text = '.'
- @text = @media.find(&:video?) ? '📹' : '🖼' if @media.size > 0
+ if @media.find(&:video?) || @media.find(&:gifv?)
+ @text = '📹'
+ elsif @media.find(&:audio?)
+ @text = '🎵'
+ elsif @media.find(&:image?)
+ @text = '🖼'
+ end
end
@visibility = @options[:visibility] || @account.user&.setting_default_privacy
@visibility = :unlisted if @visibility == :public && @account.silenced?
diff --git a/app/validators/domain_validator.rb b/app/validators/domain_validator.rb
index ae07f17980..6e4a854ff2 100644
--- a/app/validators/domain_validator.rb
+++ b/app/validators/domain_validator.rb
@@ -4,14 +4,22 @@ class DomainValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
- record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(value)
+ domain = begin
+ if options[:acct]
+ value.split('@').last
+ else
+ value
+ end
+ end
+
+ record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(domain)
end
private
def compliant?(value)
Addressable::URI.new.tap { |uri| uri.host = value }
- rescue Addressable::URI::InvalidURIError
+ rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
false
end
end
diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb
index 96fbedcfcf..9b50099668 100644
--- a/app/validators/email_mx_validator.rb
+++ b/app/validators/email_mx_validator.rb
@@ -14,6 +14,7 @@ class EmailMxValidator < ActiveModel::Validator
return true if domain.nil?
+ domain = TagManager.instance.normalize_domain(domain)
hostnames = []
ips = []
@@ -29,6 +30,8 @@ class EmailMxValidator < ActiveModel::Validator
end
ips.empty? || on_blacklist?(hostnames + ips)
+ rescue Addressable::URI::InvalidURIError
+ true
end
def on_blacklist?(values)
diff --git a/app/views/application/_card.html.haml b/app/views/application/_card.html.haml
index 00254c40c7..8719ce4844 100644
--- a/app/views/application/_card.html.haml
+++ b/app/views/application/_card.html.haml
@@ -9,7 +9,7 @@
= image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
.display-name
- %span{id: "default_account_display_name", style: "display:none;"}= account.username
+ %span{ id: "default_account_display_name", style: "display: none" }= account.username
%bdi
%strong.emojify.p-name= display_name(account, custom_emojify: true)
%span
diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml
index 6608a5dcbc..811080eb4a 100644
--- a/app/views/directories/index.html.haml
+++ b/app/views/directories/index.html.haml
@@ -14,58 +14,43 @@
%h1= t('directories.explore_mastodon', title: site_title)
%p= t('directories.explanation')
-.grid
- .column-0
- - if @accounts.empty?
- = nothing_here
- - else
- .directory
- %table.accounts-table
- %tbody
- - @accounts.each do |account|
- %tr
- %td= account_link_to account
- %td.accounts-table__count.optional
- = number_to_human account.statuses_count, strip_insignificant_zeros: true
- %small= t('accounts.posts', count: account.statuses_count).downcase
- %td.accounts-table__count.optional
- = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true)
- %small= t('accounts.followers', count: account.followers_count).downcase
- %td.accounts-table__count
- - if account.last_status_at.present?
- %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
- - else
- \-
- %small= t('accounts.last_active')
+- if @accounts.empty?
+ = nothing_here
+- else
+ .directory__list
+ - @accounts.each do |account|
+ .directory__card
+ .directory__card__img
+ = image_tag account.header.url, alt: ''
+ .directory__card__bar
+ = link_to TagManager.instance.url_for(account), class: 'directory__card__bar__name' do
+ .avatar
+ = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
- = paginate @accounts
+ .display-name
+ %span{ id: "default_account_display_name", style: "display: none" }= account.username
+ %bdi
+ %strong.emojify.p-name= display_name(account, custom_emojify: true)
+ %span= acct(account)
+ .directory__card__bar__relationship.account__relationship
+ = minimal_account_action_button(account)
- .column-1
- - if user_signed_in?
- .box-widget.notice-widget
- - if current_account.discoverable?
- - if current_account.followers_count < Account::MIN_FOLLOWERS_DISCOVERY
- %p= t('directories.enabled_but_waiting', min_followers: Account::MIN_FOLLOWERS_DISCOVERY)
- - else
- %p= t('directories.enabled')
- - else
- %p= t('directories.how_to_enable')
+ .directory__card__extra
+ .account__header__content.emojify= Formatter.instance.simplified_format(account, custom_emojify: true)
- = link_to settings_profile_path do
- = t('settings.edit_profile')
- = fa_icon 'chevron-right fw'
+ .directory__card__extra
+ .accounts-table__count
+ = number_to_human account.statuses_count, strip_insignificant_zeros: true
+ %small= t('accounts.posts', count: account.statuses_count).downcase
+ .accounts-table__count
+ = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true)
+ %small= t('accounts.followers', count: account.followers_count).downcase
+ .accounts-table__count
+ - if account.last_status_at.present?
+ %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
+ - else
+ = t('invites.expires_in_prompt')
- - if @tags.empty? && !user_signed_in?
- .nothing-here
- - else
- - @tags.each do |tag|
- .directory__tag{ class: tag.id == @tag&.id ? 'active' : nil }
- = link_to explore_hashtag_path(tag) do
- %h4
- = fa_icon 'hashtag'
- = tag.name
- %small= t('directories.people', count: tag.accounts_count)
+ %small= t('accounts.last_active')
- .avatar-stack
- - tag.cached_sample_accounts.each do |account|
- = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar'
+ = paginate @accounts
diff --git a/app/views/errors/400.html.haml b/app/views/errors/400.html.haml
new file mode 100644
index 0000000000..11fbdd40cf
--- /dev/null
+++ b/app/views/errors/400.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+ = t('errors.400')
+
+- content_for :content do
+ = t('errors.400')
diff --git a/app/views/errors/406.html.haml b/app/views/errors/406.html.haml
new file mode 100644
index 0000000000..0ef815df32
--- /dev/null
+++ b/app/views/errors/406.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+ = t('errors.406')
+
+- content_for :content do
+ = t('errors.406')
diff --git a/app/views/errors/503.html.haml b/app/views/errors/503.html.haml
new file mode 100644
index 0000000000..b0c895aa5d
--- /dev/null
+++ b/app/views/errors/503.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+ = t('errors.503')
+
+- content_for :content do
+ = t('errors.503')
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 9f794ca6b1..1f62e07d85 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -28,7 +28,7 @@
- if Setting.profile_directory
.fields-group
- = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable_html', min_followers: Account::MIN_FOLLOWERS_DISCOVERY, path: explore_path), recommended: true
+ = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true
%hr.spacer/
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index 030a57bb45..1105f2062f 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -42,11 +42,11 @@
- unless @warning.text.blank?
= Formatter.instance.linkify(@warning.text)
- - unless @statuses.empty?
+ - unless @statuses&.empty?
%p
%strong= t('user_mailer.warning.statuses')
-- unless @statuses.empty?
+- unless @statuses&.empty?
- @statuses.each_with_index do |status, i|
= render 'notification_mailer/status', status: status, i: i + 1, highlighted: true
diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb
index 24c1f86f2b..45ad3b64d4 100644
--- a/app/views/user_mailer/warning.text.erb
+++ b/app/views/user_mailer/warning.text.erb
@@ -7,7 +7,7 @@
<% end %>
<%= @warning.text %>
-<% unless @statuses.empty? %>
+<% unless @statuses&.empty? %>
<%= t('user_mailer.warning.statuses') %>
<% @statuses.each do |status| %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8e5ee8543b..98783da45f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -643,14 +643,8 @@ en:
warning_title: Disseminated content availability
directories:
directory: Profile directory
- enabled: You are currently listed in the directory.
- enabled_but_waiting: You have opted-in to be listed in the directory, but you do not have the minimum number of followers (%{min_followers}) to be listed yet.
explanation: Discover users based on their interests
explore_mastodon: Explore %{title}
- how_to_enable: You are not currently opted-in to the directory. You can opt-in below. Use hashtags in your bio text to be listed under specific hashtags!
- people:
- one: "%{count} person"
- other: "%{count} people"
domain_blocks:
blocked_domains: List of limited and blocked domains
description: This is the list of servers that %{instance} limits or reject federation with.
@@ -671,8 +665,10 @@ en:
domain_validator:
invalid_domain: is not a valid domain name
errors:
+ '400': The request you submitted was invalid or malformed.
'403': You don't have permission to view this page.
'404': The page you are looking for isn't here.
+ '406': This page is not available in the requested format.
'410': The page you were looking for doesn't exist here anymore.
'422':
content: Security verification failed. Are you blocking cookies?
@@ -681,6 +677,7 @@ en:
'500':
content: We're sorry, but something went wrong on our end.
title: This page is not correct
+ '503': The page could not be served due to a temporary server failure.
noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the native apps for Mastodon for your platform.
existing_username_validator:
not_found: could not find a local user with that username
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 14378b7bd3..6c315b0ed3 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -16,7 +16,7 @@ en:
bot: This account mainly performs automated actions and might not be monitored
context: One or multiple contexts where the filter should apply
digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
- discoverable_html: The directory lets people find accounts based on interests and activity. Requires at least %{min_followers} followers
+ discoverable: The profile directory is another way by which your account can reach a wider audience
email: You will be sent a confirmation e-mail
fields: You can have up to 4 items displayed as a table on your profile
header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
diff --git a/config/routes.rb b/config/routes.rb
index 789b5f5029..a7e65b034b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -6,6 +6,8 @@ require 'sidekiq-scheduler/web'
Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base]
Rails.application.routes.draw do
+ root 'home#index'
+
mount LetterOpenerWeb::Engine, at: 'letter_opener' if Rails.env.development?
authenticate :user, lambda { |u| u.admin? } do
@@ -336,6 +338,7 @@ Rails.application.routes.draw do
end
resource :domain_blocks, only: [:show, :create, :destroy]
+ resource :directory, only: [:show]
resources :follow_requests, only: [:index] do
member do
@@ -440,10 +443,6 @@ Rails.application.routes.draw do
get '/about/blocks', to: 'about#blocks'
get '/terms', to: 'about#terms'
- root 'home#index'
-
- match '*unmatched_route',
- via: :all,
- to: 'application#raise_not_found',
- format: false
+ match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false
+ match '*unmatched_route', via: :all, to: 'application#raise_not_found', format: false
end
diff --git a/dist/nginx.conf b/dist/nginx.conf
index 7c429bad42..b6591e8976 100644
--- a/dist/nginx.conf
+++ b/dist/nginx.conf
@@ -19,7 +19,7 @@ server {
listen [::]:443 ssl http2;
server_name example.com;
- ssl_protocols TLSv1.2;
+ ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
diff --git a/package.json b/package.json
index cba13911fc..11dbc57a72 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "mastodon",
"license": "AGPL-3.0-or-later",
"engines": {
- "node": ">=8.12 <12"
+ "node": ">=8.12 <13"
},
"scripts": {
"postversion": "git push --tags",
diff --git a/spec/controllers/remote_follow_controller_spec.rb b/spec/controllers/remote_follow_controller_spec.rb
index 5088c2e656..d79dd29495 100644
--- a/spec/controllers/remote_follow_controller_spec.rb
+++ b/spec/controllers/remote_follow_controller_spec.rb
@@ -66,9 +66,7 @@ describe RemoteFollowController do
end
it 'redirects to the remote location' do
- address = "http://example.com/follow_me?acct=test_user%40#{Rails.configuration.x.local_domain}"
-
- expect(response).to redirect_to(address)
+ expect(response).to redirect_to("http://example.com/follow_me?acct=https%3A%2F%2F#{Rails.configuration.x.local_domain}%2Fusers%2Ftest_user")
end
end
end
diff --git a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb
index 478f245855..2222a7559b 100644
--- a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb
+++ b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb
@@ -50,7 +50,8 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
describe 'when form_two_factor_confirmation parameter is not provided' do
it 'raises ActionController::ParameterMissing' do
- expect { post :create, params: {} }.to raise_error(ActionController::ParameterMissing)
+ post :create, params: {}
+ expect(response).to have_http_status(400)
end
end
diff --git a/spec/controllers/settings/two_factor_authentications_controller_spec.rb b/spec/controllers/settings/two_factor_authentications_controller_spec.rb
index 9f27222ad3..f7c6287569 100644
--- a/spec/controllers/settings/two_factor_authentications_controller_spec.rb
+++ b/spec/controllers/settings/two_factor_authentications_controller_spec.rb
@@ -112,7 +112,8 @@ describe Settings::TwoFactorAuthenticationsController do
end
it 'raises ActionController::ParameterMissing if code is missing' do
- expect { post :destroy }.to raise_error(ActionController::ParameterMissing)
+ post :destroy
+ expect(response).to have_http_status(400)
end
end
diff --git a/spec/models/remote_follow_spec.rb b/spec/models/remote_follow_spec.rb
index ed2667b28a..5b4c19b5bb 100644
--- a/spec/models/remote_follow_spec.rb
+++ b/spec/models/remote_follow_spec.rb
@@ -61,7 +61,7 @@ RSpec.describe RemoteFollow do
subject { remote_follow.subscribe_address_for(account) }
it 'returns subscribe address' do
- is_expected.to eq 'https://quitter.no/main/ostatussub?profile=alice%40cb6e6126.ngrok.io'
+ is_expected.to eq 'https://quitter.no/main/ostatussub?profile=https%3A%2F%2Fcb6e6126.ngrok.io%2Fusers%2Falice'
end
end
end